From 2fdbb289ed982b34a3016c2af7488993d9eaac6f Mon Sep 17 00:00:00 2001 From: solopole Date: Fri, 8 May 2026 11:56:55 +0800 Subject: [PATCH] feat(discord): improve channel activation and progress display (#292) --- frontends/dcapp.py | 257 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 238 insertions(+), 19 deletions(-) diff --git a/frontends/dcapp.py b/frontends/dcapp.py index 6a2ee1cb..481196d8 100644 --- a/frontends/dcapp.py +++ b/frontends/dcapp.py @@ -3,7 +3,7 @@ # Bot → Privileged Gateway Intents → MESSAGE CONTENT INTENT → 打开 # pip install discord.py -import asyncio, os, re, sys, threading, time +import asyncio, json, os, queue as Q, re, sys, threading, time from collections import OrderedDict sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -11,6 +11,8 @@ from agentmain import GeneraticAgent from chatapp_common import ( AgentChatMixin, build_done_text, ensure_single_instance, extract_files, public_access, redirect_log, require_runtime, split_text, strip_files, clean_reply, + HELP_TEXT, FILE_HINT, format_restore, + _handle_continue_frontend, _reset_conversation, ) from llmcore import mykeys @@ -24,10 +26,46 @@ agent = GeneraticAgent(); agent.verbose = False BOT_TOKEN = str(mykeys.get("discord_bot_token", "") or "").strip() ALLOWED = {str(x).strip() for x in mykeys.get("discord_allowed_users", []) if str(x).strip()} USER_TASKS = {} -MEDIA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp", "discord_media") +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TEMP_DIR = os.path.join(PROJECT_ROOT, "temp") +MEDIA_DIR = os.path.join(TEMP_DIR, "discord_media") +ACTIVE_FILE = os.path.join(TEMP_DIR, "discord_active_channels.json") +ACTIVE_TTL_SECONDS = 30 * 24 * 3600 +EXIT_CHANNEL_TEXTS = {"退出该频道", "退出此频道", "退出频道"} +EXIT_THREAD_TEXTS = {"退出该子区", "退出此子区", "退出子区"} os.makedirs(MEDIA_DIR, exist_ok=True) +def _extract_discord_progress(text): + """Return the newest concise from a streaming transcript.""" + matches = re.findall(r"\s*(.*?)\s*", text or "", flags=re.DOTALL) + if not matches: + return "" + summary = re.sub(r"\s+", " ", matches[-1]).strip() + return summary[:120] + + +def _strip_discord_transcript(text): + """Hide LLM/tool transcript noise while preserving the final natural reply.""" + text = text or "" + text = re.sub(r"^\s*\*?\*?LLM Running \(Turn \d+\) \.\.\.\*?\*?\s*$", "", text, flags=re.M) + text = re.sub(r"^\s*🛠️\s+.*?(?=^\s*(?:\*?\*?LLM Running||$))", "", text, flags=re.M | re.DOTALL) + text = re.sub(r"^\s*(?:✅|❌|ERR|STDOUT|PAT\b|RC\b).*?$", "", text, flags=re.M) + text = re.sub(r".*?", "", text, flags=re.DOTALL) + text = clean_reply(text) + return strip_files(text).strip() + + +def _display_done_text(text): + body = _strip_discord_transcript(text) + if body and body != "...": + return body + summaries = re.findall(r"\s*(.*?)\s*", text or "", flags=re.DOTALL) + if summaries: + return re.sub(r"\s+", " ", summaries[-1]).strip() or "..." + return "..." + + class DiscordApp(AgentChatMixin): label, source, split_limit = "Discord", "discord", 1900 @@ -41,6 +79,10 @@ class DiscordApp(AgentChatMixin): self.client = discord.Client(intents=intents, proxy=proxy) self.background_tasks = set() self._channel_cache = OrderedDict() # chat_id -> channel/user object (LRU, max 500) + self._active_channels = self._load_active_channels() # guild chat_id -> {last_seen: float} + self._active_lock = threading.Lock() + self._agents = OrderedDict() # chat_id -> GeneraticAgent, each chat has isolated history + self._agent_lock = threading.Lock() @self.client.event async def on_ready(): @@ -56,6 +98,85 @@ class DiscordApp(AgentChatMixin): return f"dm:{message.author.id}" return f"ch:{message.channel.id}" + def _load_active_channels(self): + try: + with open(ACTIVE_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + return {} + now = time.time() + active = {} + for chat_id, item in data.items(): + if not str(chat_id).startswith("ch:") or not isinstance(item, dict): + continue + last_seen = float(item.get("last_seen") or 0) + if now - last_seen <= ACTIVE_TTL_SECONDS: + active[str(chat_id)] = {"last_seen": last_seen} + return active + except FileNotFoundError: + return {} + except Exception as e: + print(f"[Discord] failed to load active channels: {e}") + return {} + + def _save_active_channels(self): + try: + os.makedirs(os.path.dirname(ACTIVE_FILE), exist_ok=True) + tmp = ACTIVE_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(self._active_channels, f, ensure_ascii=False, indent=2, sort_keys=True) + os.replace(tmp, ACTIVE_FILE) + except Exception as e: + print(f"[Discord] failed to save active channels: {e}") + + def _is_active_channel(self, chat_id, now=None): + now = now or time.time() + with self._active_lock: + item = self._active_channels.get(chat_id) + if not item: + return False + if now - float(item.get("last_seen") or 0) > ACTIVE_TTL_SECONDS: + self._active_channels.pop(chat_id, None) + self._save_active_channels() + print(f"[Discord] channel expired: {chat_id}") + return False + return True + + def _touch_active_channel(self, chat_id, now=None): + if not chat_id.startswith("ch:"): + return + with self._active_lock: + self._active_channels[chat_id] = {"last_seen": float(now or time.time())} + self._save_active_channels() + + def _deactivate_channel(self, chat_id): + with self._active_lock: + changed = self._active_channels.pop(chat_id, None) is not None + self._save_active_channels() + state = self.user_tasks.get(chat_id) + if state: + state["running"] = False + try: + self._get_agent(chat_id).abort() + except Exception as e: + print(f"[Discord] deactivate abort failed for {chat_id}: {e}") + return changed + + def _get_agent(self, chat_id): + with self._agent_lock: + ga = self._agents.get(chat_id) + if ga is None: + ga = GeneraticAgent() + ga.verbose = False + self._agents[chat_id] = ga + threading.Thread(target=ga.run, daemon=True, name=f"discord-agent-{chat_id}").start() + if len(self._agents) > 200: + old_chat_id, _old_agent = self._agents.popitem(last=False) + print(f"[Discord] dropped agent cache entry: {old_chat_id}") + else: + self._agents.move_to_end(chat_id) + return ga + async def _download_attachments(self, message): """Download attachments/images to MEDIA_DIR, return list of local paths.""" paths = [] @@ -95,10 +216,10 @@ class DiscordApp(AgentChatMixin): async def send_done(self, chat_id, raw_text, **ctx): """Send final reply: text parts + file attachments.""" files = [p for p in extract_files(raw_text) if os.path.exists(p)] - body = strip_files(clean_reply(raw_text)) + body = _display_done_text(raw_text) # Send text (send_text handles splitting internally) - if body: + if body and body != "...": await self.send_text(chat_id, body, **ctx) # Send files as Discord attachments @@ -115,6 +236,90 @@ class DiscordApp(AgentChatMixin): if not body and not files: await self.send_text(chat_id, "...", **ctx) + async def handle_command(self, chat_id, cmd, **ctx): + """Handle slash commands against the per-chat agent, keeping Discord chats isolated.""" + ga = self._get_agent(chat_id) + parts = (cmd or "").split() + op = (parts[0] if parts else "").lower() + if op == "/help": + return await self.send_text(chat_id, HELP_TEXT, **ctx) + if op == "/stop": + state = self.user_tasks.get(chat_id) + if state: + state["running"] = False + ga.abort() + return await self.send_text(chat_id, "⏹️ 正在停止...", **ctx) + if op == "/status": + llm = ga.get_llm_name() if ga.llmclient else "未配置" + return await self.send_text(chat_id, f"状态: {'🔴 运行中' if ga.is_running else '🟢 空闲'}\nLLM: [{ga.llm_no}] {llm}", **ctx) + if op == "/llm": + if not ga.llmclient: + return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置", **ctx) + if len(parts) > 1: + try: + ga.next_llm(int(parts[1])) + return await self.send_text(chat_id, f"✅ 已切换到 [{ga.llm_no}] {ga.get_llm_name()}", **ctx) + except Exception: + return await self.send_text(chat_id, f"用法: /llm <0-{len(ga.list_llms()) - 1}>", **ctx) + lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in ga.list_llms()] + return await self.send_text(chat_id, "LLMs:\n" + "\n".join(lines), **ctx) + if op == "/restore": + try: + restored_info, err = format_restore() + if err: + return await self.send_text(chat_id, err, **ctx) + restored, fname, count = restored_info + ga.abort() + ga.history.extend(restored) + return await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", **ctx) + except Exception as e: + return await self.send_text(chat_id, f"❌ 恢复失败: {e}", **ctx) + if op == "/continue": + return await self.send_text(chat_id, _handle_continue_frontend(ga, cmd), **ctx) + if op == "/new": + return await self.send_text(chat_id, _reset_conversation(ga), **ctx) + return await self.send_text(chat_id, HELP_TEXT, **ctx) + + async def run_agent(self, chat_id, text, **ctx): + """Run the isolated per-chat Discord agent.""" + ga = self._get_agent(chat_id) + state = {"running": True} + self.user_tasks[chat_id] = state + try: + await self.send_text(chat_id, "思考中...", **ctx) + dq = ga.put_task(f"{FILE_HINT}\n\n{text}", source=self.source) + last_ping = time.time() + last_step = "" + step_no = 0 + while state["running"]: + try: + item = await asyncio.to_thread(dq.get, True, 3) + except Q.Empty: + if ga.is_running and time.time() - last_ping > self.ping_interval: + await self.send_text(chat_id, "⏳ 还在处理中,请稍等...", **ctx) + last_ping = time.time() + continue + if "next" in item: + step = _extract_discord_progress(item.get("next", "")) + if step and step != last_step: + step_no += 1 + await self.send_text(chat_id, f"步骤{step_no}:{step}", **ctx) + last_step = step + last_ping = time.time() + continue + if "done" in item: + await self.send_done(chat_id, item.get("done", ""), **ctx) + break + if not state["running"]: + await self.send_text(chat_id, "⏹️ 已停止", **ctx) + except Exception as e: + import traceback + print(f"[{self.label}] run_agent error: {e}") + traceback.print_exc() + await self.send_text(chat_id, f"❌ 错误: {e}", **ctx) + finally: + self.user_tasks.pop(chat_id, None) + async def _handle_message(self, message): # Ignore self if message.author == self.client.user or message.author.bot: @@ -122,15 +327,13 @@ class DiscordApp(AgentChatMixin): is_dm = isinstance(message.channel, discord.DMChannel) is_guild = message.guild is not None + chat_id = self._chat_id(message) + now = time.time() + mentioned = bool(is_guild and self.client.user and self.client.user.mentioned_in(message)) - # In guild channels, only respond when @mentioned - if is_guild and not self.client.user.mentioned_in(message): - return - - # Strip bot mention from content - content = message.content or "" - if is_guild and self.client.user: - content = re.sub(rf"<@!?{self.client.user.id}>", "", content).strip() + self._channel_cache[chat_id] = message.channel + if len(self._channel_cache) > 500: + self._channel_cache.popitem(last=False) user_id = str(message.author.id) user_name = str(message.author) @@ -139,6 +342,28 @@ class DiscordApp(AgentChatMixin): print(f"[Discord] unauthorized user: {user_name} ({user_id})") return + if is_guild: + active = self._is_active_channel(chat_id, now) + if not mentioned and not active: + return + if mentioned or active: + self._touch_active_channel(chat_id, now) + + # Strip bot mention from content + content = message.content or "" + if is_guild and self.client.user: + content = re.sub(rf"<@!?{self.client.user.id}>", "", content).strip() + else: + content = content.strip() + + normalized = re.sub(r"\s+", "", content) + if is_guild and normalized in EXIT_CHANNEL_TEXTS | EXIT_THREAD_TEXTS: + self._deactivate_channel(chat_id) + label = "子区" if normalized in EXIT_THREAD_TEXTS else "频道" + await self.send_text(chat_id, f"✅ 已退出该{label},之后除非重新 @ 我,否则不会主动响应。") + print(f"[Discord] manually deactivated {chat_id} by {user_name} ({user_id})") + return + # Download attachments attachment_paths = await self._download_attachments(message) @@ -150,11 +375,6 @@ class DiscordApp(AgentChatMixin): if not content: return - chat_id = self._chat_id(message) - self._channel_cache[chat_id] = message.channel - if len(self._channel_cache) > 500: - self._channel_cache.popitem(last=False) - print(f"[Discord] message from {user_name} ({user_id}, {'dm' if is_dm else 'guild'}): {content[:200]}") if content.startswith("/"): @@ -184,5 +404,4 @@ if __name__ == "__main__": _LOCK_SOCK = ensure_single_instance(19532, "Discord") require_runtime(agent, "Discord", discord_bot_token=BOT_TOKEN) redirect_log(__file__, "dcapp.log", "Discord", ALLOWED) - threading.Thread(target=agent.run, daemon=True).start() - asyncio.run(DiscordApp().start()) \ No newline at end of file + asyncio.run(DiscordApp().start())