From 6fe93e9cb43a11bdd9b610c9ef31366221e6ff14 Mon Sep 17 00:00:00 2001 From: Vivi 8n24 Date: Fri, 13 Mar 2026 15:55:49 +0800 Subject: [PATCH] Add chat app adapters for QQ, Feishu, WeCom, and DingTalk --- README.md | 198 ++++++++++++++++- agent_loop.py | 6 +- agentmain.py | 18 +- dingtalkapp.py | 317 +++++++++++++++++++++++++++ fsapp.py | 543 +++++++++++++++++++++++++++++++++++++++++----- launch.pyw | 34 ++- llmcore.py | 121 +++++++++-- mykey_template.py | 13 ++ qqapp.py | 307 ++++++++++++++++++++++++++ wecomapp.py | 274 +++++++++++++++++++++++ 10 files changed, 1752 insertions(+), 79 deletions(-) create mode 100644 dingtalkapp.py create mode 100644 qqapp.py create mode 100644 wecomapp.py diff --git a/README.md b/README.md index e83d168..bd7fe4a 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,100 @@ cp mykey_template.py mykey.py python launch.pyw ``` +## QQ Bot (Optional) + +QQ support uses `qq-botpy` over WebSocket, so no public webhook is required. + +```bash +pip install qq-botpy +``` + +Then add these fields to `mykey.py` or `mykey.json`: + +```python +qq_app_id = "YOUR_APP_ID" +qq_app_secret = "YOUR_APP_SECRET" +qq_allowed_users = ["YOUR_USER_OPENID"] # or ['*'] for public access +``` + +Run QQ directly: + +```bash +python qqapp.py +``` + +Or start it together with the desktop window: + +```bash +python launch.pyw --qq +``` + +Notes: +- Create the bot at [QQ Open Platform](https://q.qq.com) +- In sandbox mode, add your own QQ account to the message list first +- After the first inbound message, the user's openid will be written to `temp/qqapp.log` + +## Feishu / WeCom / DingTalk (Optional) + +Feishu: + +```bash +pip install lark-oapi +python fsapp.py +# or +python launch.pyw --feishu +``` + +Config keys in `mykey.py` / `mykey.json`: + +```python +fs_app_id = "cli_xxx" +fs_app_secret = "xxx" +fs_allowed_users = ["ou_xxx"] # or ['*'] +``` + +Current Feishu support in this repo: +- inbound: text, post rich text, image, file, audio, media, interactive/share cards +- images are sent to multimodal-capable OpenAI-compatible backends as true image inputs on the first turn +- outbound: interactive progress cards, uploaded image replies, uploaded file/media replies + +Detailed setup guide: `assets/SETUP_FEISHU.md` + +WeCom: + +```bash +pip install wecom_aibot_sdk +python wecomapp.py +# or +python launch.pyw --wecom +``` + +Config keys: + +```python +wecom_bot_id = "your_bot_id" +wecom_secret = "your_bot_secret" +wecom_allowed_users = ["your_user_id"] # or ['*'] +wecom_welcome_message = "Hello" +``` + +DingTalk: + +```bash +pip install dingtalk-stream +python dingtalkapp.py +# or +python launch.pyw --dingtalk +``` + +Config keys: + +```python +dingtalk_client_id = "your_app_key" +dingtalk_client_secret = "your_app_secret" +dingtalk_allowed_users = ["your_staff_id"] # or ['*'] +``` + **Also runs on Android** — tested successfully on Termux with `python agentmain.py` (CLI frontend): ```bash @@ -142,6 +236,10 @@ The agent starts with 7 primitive tools. Through `code_run`, it can install pack **Interface** (talk to the agent): - `stapp.py` — Streamlit web UI - `tgapp.py` — Telegram bot interface +- `fsapp.py` — Feishu bot interface +- `qqapp.py` — QQ bot interface +- `wecomapp.py` — WeCom bot interface +- `dingtalkapp.py` — DingTalk bot interface - `launch.pyw` — One-click launcher with floating window **Infrastructure**: @@ -230,6 +328,100 @@ python agentmain.py 启动后告诉 Agent:"执行 web setup SOP 解锁浏览器工具"——剩下的它自己搞定。完整引导流程见 [WELCOME_NEW_USER.md](WELCOME_NEW_USER.md)。 +## QQ Bot(可选) + +QQ 适配使用 `qq-botpy` 的 WebSocket 长连接,不需要公网 webhook。 + +```bash +pip install qq-botpy +``` + +然后在 `mykey.py` 或 `mykey.json` 中补充: + +```python +qq_app_id = "YOUR_APP_ID" +qq_app_secret = "YOUR_APP_SECRET" +qq_allowed_users = ["YOUR_USER_OPENID"] # 或 ['*'] 表示公开访问 +``` + +启动方式: + +```bash +python qqapp.py +``` + +或和桌面悬浮窗一起启动: + +```bash +python launch.pyw --qq +``` + +补充说明: +- 在 [QQ 开放平台](https://q.qq.com) 创建机器人并拿到 `AppID` / `AppSecret` +- 沙箱调试时,先把自己的 QQ 号加入消息列表 +- 首次给机器人发消息后,用户 openid 会记录在 `temp/qqapp.log` 中,便于填入 `qq_allowed_users` + +## Feishu / WeCom / DingTalk(可选) + +Feishu: + +```bash +pip install lark-oapi +python fsapp.py +# 或 +python launch.pyw --feishu +``` + +配置项: + +```python +fs_app_id = "cli_xxx" +fs_app_secret = "xxx" +fs_allowed_users = ["ou_xxx"] # 或 ['*'] +``` + +当前仓库里的飞书能力: +- 入站:文本、富文本 post、图片、文件、音频、media、交互卡片/分享卡片 +- 图片首轮会以真正的多模态图片输入发送给支持 OpenAI 兼容视觉的模型后端 +- 出站:流式进度卡片、图片回传、文件或 media 回传 + +详细配置流程见 `assets/SETUP_FEISHU.md` + +WeCom(企业微信): + +```bash +pip install wecom_aibot_sdk +python wecomapp.py +# 或 +python launch.pyw --wecom +``` + +配置项: + +```python +wecom_bot_id = "your_bot_id" +wecom_secret = "your_bot_secret" +wecom_allowed_users = ["your_user_id"] # 或 ['*'] +wecom_welcome_message = "你好,我在线上。" +``` + +DingTalk(钉钉): + +```bash +pip install dingtalk-stream +python dingtalkapp.py +# 或 +python launch.pyw --dingtalk +``` + +配置项: + +```python +dingtalk_client_id = "your_app_key" +dingtalk_client_secret = "your_app_secret" +dingtalk_allowed_users = ["your_staff_id"] # 或 ['*'] +``` + ## 对比 | | GenericAgent | OpenClaw | Claude Code | @@ -261,6 +453,10 @@ Agent 拥有 7 个原子工具:`code_run`(执行任意代码)、`file_read **交互界面**: - `stapp.py` — Streamlit Web UI - `tgapp.py` — Telegram 机器人 +- `fsapp.py` — 飞书机器人 +- `qqapp.py` — QQ 机器人 +- `wecomapp.py` — 企业微信机器人 +- `dingtalkapp.py` — 钉钉机器人 - `launch.pyw` — 一键启动 + 悬浮窗 **基础设施**: @@ -280,4 +476,4 @@ Agent 拥有 7 个原子工具:`code_run`(执行任意代码)、`file_read ## 许可 -MIT \ No newline at end of file +MIT diff --git a/agent_loop.py b/agent_loop.py index 92f7335..41efa55 100644 --- a/agent_loop.py +++ b/agent_loop.py @@ -45,10 +45,10 @@ def get_pretty_json(data): data["script"] = data["script"].replace("; ", ";\n ") return json.dumps(data, indent=2, ensure_ascii=False).replace('\\n', '\n') -def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, max_turns=15, verbose=True): +def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, max_turns=15, verbose=True, initial_user_content=None): messages = [ {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_input} + {"role": "user", "content": initial_user_content if initial_user_content is not None else user_input} ] for turn in range(max_turns): yield f"**LLM Running (Turn {turn+1}) ...**\n\n" @@ -93,4 +93,4 @@ def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, next_prompt += outcome.next_prompt next_prompt = handler.next_prompt_patcher(next_prompt, outcome, turn+1) messages = [{"role": "user", "content": next_prompt}] - return {'result': 'MAX_TURNS_EXCEEDED'} \ No newline at end of file + return {'result': 'MAX_TURNS_EXCEEDED'} diff --git a/agentmain.py b/agentmain.py index 0e55355..4151ad5 100644 --- a/agentmain.py +++ b/agentmain.py @@ -5,7 +5,7 @@ if sys.stderr is None: sys.stderr = open(os.devnull, "w") elif hasattr(sys.stderr, 'reconfigure'): sys.stderr.reconfigure(errors='replace') sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from llmcore import SiderLLMSession, LLMSession, ToolClient, ClaudeSession, XaiSession +from llmcore import SiderLLMSession, LLMSession, ToolClient, ClaudeSession, XaiSession, build_multimodal_content from agent_loop import agent_runner_loop, StepOutcome, BaseHandler from ga import GenericAgentHandler, smart_format, get_global_memory, format_error @@ -83,16 +83,16 @@ class GeneraticAgent: if self.handler is not None: self.handler.code_stop_signal.append(1) - def put_task(self, query, source="user"): + def put_task(self, query, source="user", images=None): display_queue = queue.Queue() - self.task_queue.put({"query": query, "source": source, "output": display_queue}) + self.task_queue.put({"query": query, "source": source, "images": images or [], "output": display_queue}) return display_queue def run(self): while True: task = self.task_queue.get() self.is_running = True - raw_query, source, display_queue = task["query"], task["source"], task["output"] + raw_query, source, images, display_queue = task["query"], task["source"], task.get("images") or [], task["output"] rquery = smart_format(raw_query.replace('\n', ' '), max_str_len=200) self.history.append(f"[USER]: {rquery}") @@ -108,8 +108,14 @@ class GeneraticAgent: user_input = raw_query if source == 'feishu' and len(self.history) > 1: # 如果有历史记录且来自飞书,注入到首轮 user_input 中(支持/restore恢复上下文) user_input = handler._get_anchor_prompt() + f"\n\n### 用户当前消息\n{raw_query}" + initial_user_content = None + if images and isinstance(self.llmclient.backend, LLMSession): + initial_user_content = build_multimodal_content(user_input, images) + elif images: + print(f"[INFO] backend {type(self.llmclient.backend).__name__} does not support direct multimodal input, fallback to text attachment hints.") gen = agent_runner_loop(self.llmclient, sys_prompt, user_input, - handler, TOOLS_SCHEMA, max_turns=40, verbose=self.verbose) + handler, TOOLS_SCHEMA, max_turns=40, verbose=self.verbose, + initial_user_content=initial_user_content) try: full_resp = ""; last_pos = 0 for chunk in gen: @@ -207,4 +213,4 @@ if __name__ == '__main__': if 'done' in item: print(); break except KeyboardInterrupt: agent.abort() - print('\n[Interrupted]') \ No newline at end of file + print('\n[Interrupted]') diff --git a/dingtalkapp.py b/dingtalkapp.py new file mode 100644 index 0000000..d4a07dc --- /dev/null +++ b/dingtalkapp.py @@ -0,0 +1,317 @@ +import os, sys, re, threading, asyncio, queue as Q, socket, time, glob, json +import requests + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from agentmain import GeneraticAgent +from llmcore import mykeys + +try: + from dingtalk_stream import AckMessage, CallbackHandler, Credential, DingTalkStreamClient + from dingtalk_stream.chatbot import ChatbotMessage +except Exception: + print("Please install dingtalk-stream to use DingTalk: pip install dingtalk-stream") + sys.exit(1) + +agent = GeneraticAgent() +agent.verbose = False + +CLIENT_ID = str(mykeys.get("dingtalk_client_id", "") or "").strip() +CLIENT_SECRET = str(mykeys.get("dingtalk_client_secret", "") or "").strip() +ALLOWED = {str(x).strip() for x in mykeys.get("dingtalk_allowed_users", []) if str(x).strip()} + +_TAG_PATS = [r"<" + t + r">.*?" for t in ("thinking", "summary", "tool_use", "file_content")] +_USER_TASKS = {} + + +def _clean(text): + for pat in _TAG_PATS: + text = re.sub(pat, "", text, flags=re.DOTALL) + return re.sub(r"\n{3,}", "\n\n", text).strip() or "..." + + +def _extract_files(text): + return re.findall(r"\[FILE:([^\]]+)\]", text or "") + + +def _strip_files(text): + return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip() + + +def _split_text(text, limit=1800): + text = (text or "").strip() or "..." + parts = [] + while len(text) > limit: + cut = text.rfind("\n", 0, limit) + if cut < limit * 0.6: + cut = limit + parts.append(text[:cut].rstrip()) + text = text[cut:].lstrip() + if text: + parts.append(text) + return parts or ["..."] + + +def _format_restore(): + files = glob.glob("./temp/model_responses_*.txt") + if not files: + return None, "❌ 没有找到历史记录" + latest = max(files, key=os.path.getmtime) + with open(latest, "r", encoding="utf-8") as f: + content = f.read() + users = re.findall(r"=== USER ===\n(.+?)(?==== |$)", content, re.DOTALL) + resps = re.findall(r"=== Response ===.*?\n(.+?)(?==== Prompt|$)", content, re.DOTALL) + count, restored = 0, [] + for u, r in zip(users, resps): + u, r = u.strip(), r.strip()[:500] + if u and r: + restored.extend([f"[USER]: {u}", f"[Agent] {r}"]) + count += 1 + if not restored: + return None, "❌ 历史记录里没有可恢复内容" + return (restored, os.path.basename(latest), count), None + + +class DingTalkApp: + def __init__(self): + self.client = None + self.access_token = None + self.token_expiry = 0 + self.background_tasks = set() + + async def _get_access_token(self): + if self.access_token and time.time() < self.token_expiry: + return self.access_token + + def _fetch(): + resp = requests.post( + "https://api.dingtalk.com/v1.0/oauth2/accessToken", + json={"appKey": CLIENT_ID, "appSecret": CLIENT_SECRET}, + timeout=20, + ) + resp.raise_for_status() + return resp.json() + + try: + data = await asyncio.to_thread(_fetch) + self.access_token = data.get("accessToken") + self.token_expiry = time.time() + int(data.get("expireIn", 7200)) - 60 + return self.access_token + except Exception as e: + print(f"[DingTalk] token error: {e}") + return None + + async def _send_batch_message(self, chat_id, msg_key, msg_param): + token = await self._get_access_token() + if not token: + return False + headers = {"x-acs-dingtalk-access-token": token} + if chat_id.startswith("group:"): + url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send" + payload = { + "robotCode": CLIENT_ID, + "openConversationId": chat_id[6:], + "msgKey": msg_key, + "msgParam": json.dumps(msg_param, ensure_ascii=False), + } + else: + url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" + payload = { + "robotCode": CLIENT_ID, + "userIds": [chat_id], + "msgKey": msg_key, + "msgParam": json.dumps(msg_param, ensure_ascii=False), + } + + def _post(): + resp = requests.post(url, json=payload, headers=headers, timeout=20) + body = resp.text + if resp.status_code != 200: + raise RuntimeError(f"HTTP {resp.status_code}: {body[:300]}") + try: + result = resp.json() + except Exception: + result = {} + errcode = result.get("errcode") + if errcode not in (None, 0): + raise RuntimeError(f"API errcode={errcode}: {body[:300]}") + return True + + try: + return await asyncio.to_thread(_post) + except Exception as e: + print(f"[DingTalk] send error: {e}") + return False + + async def send_text(self, chat_id, content): + for part in _split_text(content): + await self._send_batch_message(chat_id, "sampleMarkdown", {"text": part, "title": "Agent Reply"}) + + async def send_done(self, chat_id, raw_text): + files = [p for p in _extract_files(raw_text) if os.path.exists(p)] + body = _strip_files(_clean(raw_text)) + if files: + body = (body + "\n\n" if body else "") + "\n".join([f"生成文件: {p}" for p in files]) + await self.send_text(chat_id, body or "...") + + async def handle_command(self, chat_id, cmd): + parts = (cmd or "").split() + op = (parts[0] if parts else "").lower() + if op == "/stop": + state = _USER_TASKS.get(chat_id) + if state: + state["running"] = False + agent.abort() + await self.send_text(chat_id, "⏹️ 正在停止...") + elif op == "/status": + llm = agent.get_llm_name() if agent.llmclient else "未配置" + await self.send_text(chat_id, f"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\nLLM: [{agent.llm_no}] {llm}") + elif op == "/llm": + if not agent.llmclient: + return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置") + if len(parts) > 1: + try: + n = int(parts[1]) + agent.next_llm(n) + await self.send_text(chat_id, f"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}") + except Exception: + await self.send_text(chat_id, f"用法: /llm <0-{len(agent.list_llms()) - 1}>") + else: + lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in agent.list_llms()] + await self.send_text(chat_id, "LLMs:\n" + "\n".join(lines)) + elif op == "/restore": + try: + restored_info, err = _format_restore() + if err: + return await self.send_text(chat_id, err) + restored, fname, count = restored_info + agent.abort() + agent.history.extend(restored) + await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)") + except Exception as e: + await self.send_text(chat_id, f"❌ 恢复失败: {e}") + elif op == "/new": + agent.abort() + agent.history = [] + await self.send_text(chat_id, "🆕 已清空当前共享上下文") + else: + await self.send_text( + chat_id, + "📖 命令列表:\n/help - 显示帮助\n/status - 查看状态\n/stop - 停止当前任务\n/new - 清空当前上下文\n/restore - 恢复上次对话历史\n/llm [n] - 查看或切换模型", + ) + + async def run_agent(self, chat_id, text): + state = {"running": True} + _USER_TASKS[chat_id] = state + try: + await self.send_text(chat_id, "思考中...") + prompt = f"If you need to show files to user, use [FILE:filepath] in your response.\n\n{text}" + dq = agent.put_task(prompt, source="dingtalk") + last_ping = time.time() + while state["running"]: + try: + item = await asyncio.to_thread(dq.get, True, 3) + except Q.Empty: + if agent.is_running and time.time() - last_ping > 20: + await self.send_text(chat_id, "⏳ 还在处理中,请稍等...") + last_ping = time.time() + continue + if "done" in item: + await self.send_done(chat_id, item.get("done", "")) + break + if not state["running"]: + await self.send_text(chat_id, "⏹️ 已停止") + except Exception as e: + import traceback + + print(f"[DingTalk] run_agent error: {e}") + traceback.print_exc() + await self.send_text(chat_id, f"❌ 错误: {e}") + finally: + _USER_TASKS.pop(chat_id, None) + + async def on_message(self, content, sender_id, sender_name, conversation_type=None, conversation_id=None): + try: + if not content: + return + public_access = not ALLOWED or "*" in ALLOWED + if not public_access and sender_id not in ALLOWED: + print(f"[DingTalk] unauthorized user: {sender_id}") + return + is_group = conversation_type == "2" and conversation_id + chat_id = f"group:{conversation_id}" if is_group else sender_id + print(f"[DingTalk] message from {sender_name} ({sender_id}): {content}") + if content.startswith("/"): + await self.handle_command(chat_id, content) + return + task = asyncio.create_task(self.run_agent(chat_id, content)) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + except Exception: + import traceback + + print("[DingTalk] handle_message error") + traceback.print_exc() + + async def start(self): + handler = _DingTalkHandler(self) + self.client = DingTalkStreamClient(Credential(CLIENT_ID, CLIENT_SECRET)) + self.client.register_callback_handler(ChatbotMessage.TOPIC, handler) + print("[DingTalk] bot starting...") + while True: + try: + await self.client.start() + except Exception as e: + print(f"[DingTalk] stream error: {e}") + print("[DingTalk] reconnect in 5s...") + await asyncio.sleep(5) + + +class _DingTalkHandler(CallbackHandler): + def __init__(self, app): + super().__init__() + self.app = app + + async def process(self, message): + try: + chatbot_msg = ChatbotMessage.from_dict(message.data) + text = "" + if getattr(getattr(chatbot_msg, "text", None), "content", None): + text = chatbot_msg.text.content.strip() + extensions = getattr(chatbot_msg, "extensions", None) or {} + recognition = ((extensions.get("content") or {}).get("recognition") or "").strip() if isinstance(extensions, dict) else "" + if not text: + text = recognition or str((message.data.get("text", {}) or {}).get("content", "") or "").strip() + sender_id = getattr(chatbot_msg, "sender_staff_id", None) or getattr(chatbot_msg, "sender_id", None) or "unknown" + sender_name = getattr(chatbot_msg, "sender_nick", None) or "Unknown" + conversation_type = message.data.get("conversationType") + conversation_id = message.data.get("conversationId") or message.data.get("openConversationId") + await self.app.on_message(text, str(sender_id), sender_name, conversation_type, conversation_id) + return AckMessage.STATUS_OK, "OK" + except Exception as e: + print(f"[DingTalk] callback error: {e}") + return AckMessage.STATUS_OK, "Error" + + +if __name__ == "__main__": + try: + _lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + _lock_sock.bind(("127.0.0.1", 19530)) + except OSError: + print("[DingTalk] Another instance is already running, skipping...") + sys.exit(1) + + if not CLIENT_ID or not CLIENT_SECRET: + print("[DingTalk] ERROR: please set dingtalk_client_id and dingtalk_client_secret in mykey.py or mykey.json") + sys.exit(1) + if agent.llmclient is None: + print("[DingTalk] ERROR: no usable LLM backend found in mykey.py or mykey.json") + sys.exit(1) + + log_dir = os.path.join(os.path.dirname(__file__), "temp") + os.makedirs(log_dir, exist_ok=True) + _logf = open(os.path.join(log_dir, "dingtalkapp.log"), "a", encoding="utf-8", buffering=1) + sys.stdout = sys.stderr = _logf + print("[NEW] DingTalk process starting, the above are history infos ...") + print(f"[DingTalk] allow list: {'public' if not ALLOWED or '*' in ALLOWED else sorted(ALLOWED)}") + threading.Thread(target=agent.run, daemon=True).start() + asyncio.run(DingTalkApp().start()) diff --git a/fsapp.py b/fsapp.py index 1a8459b..48d592d 100644 --- a/fsapp.py +++ b/fsapp.py @@ -1,103 +1,538 @@ -import os, sys, threading, asyncio, time, re, json +import glob +import json +import os import queue as Q +import re +import sys +import threading +import time + PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, PROJECT_ROOT); os.chdir(PROJECT_ROOT) +sys.path.insert(0, PROJECT_ROOT) +os.chdir(PROJECT_ROOT) + import lark_oapi as lark from lark_oapi.api.im.v1 import * -from lark_oapi.api.contact.v3 import * + from agentmain import GeneraticAgent -import mykey -_TAG_PATS = [r'<' + t + r'>.*?' for t in ('thinking', 'summary', 'tool_use', 'file_content')] -def _clean(t): - for p in _TAG_PATS: t = re.sub(p, '', t, flags=re.DOTALL) - return re.sub(r'\n{3,}', '\n\n', t).strip() or '...' -APP_ID, APP_SECRET = getattr(mykey, 'fs_app_id', None), getattr(mykey, 'fs_app_secret', None) -ALLOWED_USERS = set(getattr(mykey, 'fs_allowed_users', [])) +from llmcore import mykeys + +_TAG_PATS = [r"<" + t + r">.*?" for t in ("thinking", "summary", "tool_use", "file_content")] +_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} +_AUDIO_EXTS = {".opus", ".mp3", ".wav", ".m4a", ".aac"} +_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"} +_FILE_TYPE_MAP = { + ".opus": "opus", + ".mp4": "mp4", + ".pdf": "pdf", + ".doc": "doc", + ".docx": "doc", + ".xls": "xls", + ".xlsx": "xls", + ".ppt": "ppt", + ".pptx": "ppt", +} +_MSG_TYPE_MAP = {"image": "[image]", "audio": "[audio]", "file": "[file]", "media": "[media]", "sticker": "[sticker]"} + +TEMP_DIR = os.path.join(PROJECT_ROOT, "temp") +MEDIA_DIR = os.path.join(TEMP_DIR, "feishu_media") +os.makedirs(MEDIA_DIR, exist_ok=True) + + +def _clean(text): + for pat in _TAG_PATS: + text = re.sub(pat, "", text, flags=re.DOTALL) + return re.sub(r"\n{3,}", "\n\n", text).strip() or "..." + + +def _extract_files(text): + return re.findall(r"\[FILE:([^\]]+)\]", text or "") + + +def _strip_files(text): + return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip() + + +def _display_text(text): + return _strip_files(_clean(text)) or "..." + + +def _to_allowed_set(value): + if value is None: + return set() + if isinstance(value, str): + value = [value] + return {str(x).strip() for x in value if str(x).strip()} + + +def _parse_json(raw): + if not raw: + return {} + try: + return json.loads(raw) + except Exception: + return {} + + +def _extract_share_card_content(content_json, msg_type): + parts = [] + if msg_type == "share_chat": + parts.append(f"[shared chat: {content_json.get('chat_id', '')}]") + elif msg_type == "share_user": + parts.append(f"[shared user: {content_json.get('user_id', '')}]") + elif msg_type == "interactive": + parts.extend(_extract_interactive_content(content_json)) + elif msg_type == "share_calendar_event": + parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]") + elif msg_type == "system": + parts.append("[system message]") + elif msg_type == "merge_forward": + parts.append("[merged forward messages]") + return "\n".join([p for p in parts if p]).strip() or f"[{msg_type}]" + + +def _extract_interactive_content(content): + parts = [] + if isinstance(content, str): + try: + content = json.loads(content) + except Exception: + return [content] if content.strip() else [] + if not isinstance(content, dict): + return parts + title = content.get("title") + if isinstance(title, dict): + title_text = title.get("content", "") or title.get("text", "") + if title_text: + parts.append(f"title: {title_text}") + elif isinstance(title, str) and title: + parts.append(f"title: {title}") + elements = content.get("elements", []) + if isinstance(elements, list): + for row in elements: + if isinstance(row, dict): + parts.extend(_extract_element_content(row)) + elif isinstance(row, list): + for el in row: + parts.extend(_extract_element_content(el)) + card = content.get("card", {}) + if card: + parts.extend(_extract_interactive_content(card)) + header = content.get("header", {}) + if isinstance(header, dict): + header_title = header.get("title", {}) + if isinstance(header_title, dict): + header_text = header_title.get("content", "") or header_title.get("text", "") + if header_text: + parts.append(f"title: {header_text}") + return [p for p in parts if p] + + +def _extract_element_content(element): + parts = [] + if not isinstance(element, dict): + return parts + tag = element.get("tag", "") + if tag in ("markdown", "lark_md"): + content = element.get("content", "") + if content: + parts.append(content) + elif tag == "div": + text = element.get("text", {}) + if isinstance(text, dict): + text_content = text.get("content", "") or text.get("text", "") + if text_content: + parts.append(text_content) + elif isinstance(text, str) and text: + parts.append(text) + for field in element.get("fields", []) or []: + if isinstance(field, dict): + field_text = field.get("text", {}) + if isinstance(field_text, dict): + content = field_text.get("content", "") or field_text.get("text", "") + if content: + parts.append(content) + elif tag == "a": + href = element.get("href", "") + text = element.get("text", "") + if href: + parts.append(f"link: {href}") + if text: + parts.append(text) + elif tag == "button": + text = element.get("text", {}) + if isinstance(text, dict): + content = text.get("content", "") or text.get("text", "") + if content: + parts.append(content) + url = element.get("url", "") or (element.get("multi_url", {}) or {}).get("url", "") + if url: + parts.append(f"link: {url}") + elif tag == "img": + alt = element.get("alt", {}) + if isinstance(alt, dict): + parts.append(alt.get("content", "[image]") or "[image]") + else: + parts.append("[image]") + for child in element.get("elements", []) or []: + parts.extend(_extract_element_content(child)) + for col in element.get("columns", []) or []: + for child in (col.get("elements", []) if isinstance(col, dict) else []): + parts.extend(_extract_element_content(child)) + return parts + + +def _extract_post_content(content_json): + def _parse_block(block): + if not isinstance(block, dict) or not isinstance(block.get("content"), list): + return None, [] + texts, images = [], [] + if block.get("title"): + texts.append(block.get("title")) + for row in block["content"]: + if not isinstance(row, list): + continue + for el in row: + if not isinstance(el, dict): + continue + tag = el.get("tag") + if tag in ("text", "a"): + texts.append(el.get("text", "")) + elif tag == "at": + texts.append(f"@{el.get('user_name', 'user')}") + elif tag == "img" and el.get("image_key"): + images.append(el["image_key"]) + text = " ".join([t for t in texts if t]).strip() + return text or None, images + + root = content_json + if isinstance(root, dict) and isinstance(root.get("post"), dict): + root = root["post"] + if not isinstance(root, dict): + return "", [] + if "content" in root: + text, imgs = _parse_block(root) + if text or imgs: + return text or "", imgs + for key in ("zh_cn", "en_us", "ja_jp"): + if key in root: + text, imgs = _parse_block(root[key]) + if text or imgs: + return text or "", imgs + for val in root.values(): + if isinstance(val, dict): + text, imgs = _parse_block(val) + if text or imgs: + return text or "", imgs + return "", [] + + +APP_ID = str(mykeys.get("fs_app_id", "") or "").strip() +APP_SECRET = str(mykeys.get("fs_app_secret", "") or "").strip() +ALLOWED_USERS = _to_allowed_set(mykeys.get("fs_allowed_users", [])) +PUBLIC_ACCESS = not ALLOWED_USERS or "*" in ALLOWED_USERS + agent = GeneraticAgent() threading.Thread(target=agent.run, daemon=True).start() client, user_tasks = None, {} + + def create_client(): return lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET).log_level(lark.LogLevel.INFO).build() -_card = lambda t: json.dumps({"config": {"wide_screen_mode": True}, "elements": [{"tag": "markdown", "content": t}]}) + + +def _card(text): + return json.dumps({"config": {"wide_screen_mode": True}, "elements": [{"tag": "markdown", "content": text}]}, ensure_ascii=False) + + def send_message(open_id, content, msg_type="text", use_card=False): - ct, mt = (_card(content), "interactive") if use_card else (json.dumps({"text": content}), "text") + if use_card: + payload, real_type = _card(content), "interactive" + elif msg_type == "text": + payload, real_type = json.dumps({"text": content}, ensure_ascii=False), "text" + else: + payload, real_type = content, msg_type body = CreateMessageRequest.builder().receive_id_type("open_id").request_body( - CreateMessageRequestBody.builder().receive_id(open_id).msg_type(mt).content(ct).build()).build() - r = client.im.v1.message.create(body) - return r.data.message_id if r.success() else (print(f"发送失败: {r.code}, {r.msg}"), None)[1] + CreateMessageRequestBody.builder().receive_id(open_id).msg_type(real_type).content(payload).build() + ).build() + response = client.im.v1.message.create(body) + if response.success(): + return response.data.message_id if response.data else None + print(f"发送失败: {response.code}, {response.msg}") + return None + + def update_message(message_id, content): body = PatchMessageRequest.builder().message_id(message_id).request_body( - PatchMessageRequestBody.builder().content(_card(content)).build()).build() - r = client.im.v1.message.patch(body) - if not r.success(): print(f"[ERROR] update_message 失败: {r.code}, {r.msg}") - return r.success() + PatchMessageRequestBody.builder().content(_card(content)).build() + ).build() + response = client.im.v1.message.patch(body) + if not response.success(): + print(f"[ERROR] update_message 失败: {response.code}, {response.msg}") + return response.success() + + +def _upload_image_sync(file_path): + try: + with open(file_path, "rb") as f: + request = CreateImageRequest.builder().request_body( + CreateImageRequestBody.builder().image_type("message").image(f).build() + ).build() + response = client.im.v1.image.create(request) + if response.success(): + return response.data.image_key + print(f"[ERROR] upload image failed: {response.code}, {response.msg}") + except Exception as e: + print(f"[ERROR] upload image failed {file_path}: {e}") + return None + + +def _upload_file_sync(file_path): + ext = os.path.splitext(file_path)[1].lower() + file_type = _FILE_TYPE_MAP.get(ext, "stream") + file_name = os.path.basename(file_path) + try: + with open(file_path, "rb") as f: + request = CreateFileRequest.builder().request_body( + CreateFileRequestBody.builder().file_type(file_type).file_name(file_name).file(f).build() + ).build() + response = client.im.v1.file.create(request) + if response.success(): + return response.data.file_key + print(f"[ERROR] upload file failed: {response.code}, {response.msg}") + except Exception as e: + print(f"[ERROR] upload file failed {file_path}: {e}") + return None + + +def _download_image_sync(message_id, image_key): + try: + request = GetMessageResourceRequest.builder().message_id(message_id).file_key(image_key).type("image").build() + response = client.im.v1.message_resource.get(request) + if response.success(): + data = response.file.read() if hasattr(response.file, "read") else response.file + return data, response.file_name + print(f"[ERROR] download image failed: {response.code}, {response.msg}") + except Exception as e: + print(f"[ERROR] download image failed {image_key}: {e}") + return None, None + + +def _download_file_sync(message_id, file_key, resource_type="file"): + if resource_type == "audio": + resource_type = "file" + try: + request = GetMessageResourceRequest.builder().message_id(message_id).file_key(file_key).type(resource_type).build() + response = client.im.v1.message_resource.get(request) + if response.success(): + data = response.file.read() if hasattr(response.file, "read") else response.file + return data, response.file_name + print(f"[ERROR] download {resource_type} failed: {response.code}, {response.msg}") + except Exception as e: + print(f"[ERROR] download {resource_type} failed {file_key}: {e}") + return None, None + + +def _download_and_save_media(msg_type, content_json, message_id): + data, filename = None, None + if msg_type == "image": + image_key = content_json.get("image_key") + if image_key and message_id: + data, filename = _download_image_sync(message_id, image_key) + if not filename: + filename = f"{image_key[:16]}.jpg" + elif msg_type in ("audio", "file", "media"): + file_key = content_json.get("file_key") + if file_key and message_id: + data, filename = _download_file_sync(message_id, file_key, msg_type) + if not filename: + filename = file_key[:16] + if msg_type == "audio" and filename and not filename.endswith(".opus"): + filename = f"{filename}.opus" + if data and filename: + file_path = os.path.join(MEDIA_DIR, os.path.basename(filename)) + with open(file_path, "wb") as f: + f.write(data) + return file_path, filename + return None, None + + +def _describe_media(msg_type, file_path, filename): + if msg_type == "image": + return f"[image: {filename}]\n[Image: source: {file_path}]" + if msg_type == "audio": + return f"[audio: {filename}]\n[File: source: {file_path}]" + if msg_type in ("file", "media"): + return f"[{msg_type}: {filename}]\n[File: source: {file_path}]" + return f"[{msg_type}]\n[File: source: {file_path}]" + + +def _send_local_file(open_id, file_path): + if not os.path.isfile(file_path): + send_message(open_id, f"⚠️ 文件不存在: {file_path}") + return False + ext = os.path.splitext(file_path)[1].lower() + if ext in _IMAGE_EXTS: + image_key = _upload_image_sync(file_path) + if image_key: + send_message(open_id, json.dumps({"image_key": image_key}, ensure_ascii=False), msg_type="image") + return True + else: + file_key = _upload_file_sync(file_path) + if file_key: + msg_type = "media" if ext in _AUDIO_EXTS or ext in _VIDEO_EXTS else "file" + send_message(open_id, json.dumps({"file_key": file_key}, ensure_ascii=False), msg_type=msg_type) + return True + send_message(open_id, f"⚠️ 文件发送失败: {os.path.basename(file_path)}") + return False + + +def _send_generated_files(open_id, raw_text): + for file_path in _extract_files(raw_text): + _send_local_file(open_id, file_path) + + +def _build_user_message(message): + msg_type = message.message_type + message_id = message.message_id + content_json = _parse_json(message.content) + parts, image_paths = [], [] + if msg_type == "text": + text = str(content_json.get("text", "") or "").strip() + if text: + parts.append(text) + elif msg_type == "post": + text, image_keys = _extract_post_content(content_json) + if text: + parts.append(text) + for image_key in image_keys: + file_path, filename = _download_and_save_media("image", {"image_key": image_key}, message_id) + if file_path and filename: + parts.append(_describe_media("image", file_path, filename)) + image_paths.append(file_path) + else: + parts.append("[image: download failed]") + elif msg_type in ("image", "audio", "file", "media"): + file_path, filename = _download_and_save_media(msg_type, content_json, message_id) + if file_path and filename: + parts.append(_describe_media(msg_type, file_path, filename)) + if msg_type == "image": + image_paths.append(file_path) + else: + parts.append(f"[{msg_type}: download failed]") + elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"): + parts.append(_extract_share_card_content(content_json, msg_type)) + else: + parts.append(_MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) + return "\n".join([p for p in parts if p]).strip(), image_paths + + def handle_message(data): event, message, sender = data.event, data.event.message, data.event.sender - open_id, msg_type = sender.sender_id.open_id, message.message_type - if ALLOWED_USERS and open_id not in ALLOWED_USERS: return print(f"未授权用户: {open_id}") - if msg_type != "text": return send_message(open_id, "⚠️ 目前只支持文本消息") - text = json.loads(message.content).get("text", "").strip() - if not text: return - print(f"收到消息 [{open_id}]: {text}") - if text.startswith("/"): return handle_command(open_id, text) + open_id = sender.sender_id.open_id + if not PUBLIC_ACCESS and open_id not in ALLOWED_USERS: + print(f"未授权用户: {open_id}") + return + user_input, image_paths = _build_user_message(message) + if not user_input: + send_message(open_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}") + return + print(f"收到消息 [{open_id}] ({message.message_type}, {len(image_paths)} images): {user_input[:200]}") + if message.message_type == "text" and user_input.startswith("/"): + return handle_command(open_id, user_input) + def run_agent(): - user_tasks[open_id] = {'running': True} + user_tasks[open_id] = {"running": True} try: - msg_id, dq, last_text = send_message(open_id, "思考中...", use_card=True), agent.put_task(text, source='feishu'), "" - while user_tasks.get(open_id, {}).get('running', False): + msg_id, dq, last_text = send_message(open_id, "思考中...", use_card=True), agent.put_task(user_input, source="feishu", images=image_paths), "" + while user_tasks.get(open_id, {}).get("running", False): time.sleep(3) item = None try: - while True: item = dq.get_nowait() - except: pass - if item is None: continue - raw, done = item.get("done") or item.get("next", ""), "done" in item - show = _clean(raw) + while True: + item = dq.get_nowait() + except Exception: + pass + if item is None: + continue + raw = item.get("done") or item.get("next", "") + done = "done" in item + show = _display_text(raw) if len(show) > 3500: - # 智能截断:避免切断代码块 cut = show[-3000:] - if cut.count('```') % 2 == 1: cut = '```\n' + cut # 补开头 + if cut.count("```") % 2 == 1: + cut = "```\n" + cut msg_id, last_text, show = send_message(open_id, "(继续...)", use_card=True), "", cut display = show if done else show + " ⏳" - if display != last_text and msg_id: update_message(msg_id, display); last_text = display - if done: break - if not user_tasks.get(open_id, {}).get('running', True): send_message(open_id, "⏹️ 已停止") + if display != last_text and msg_id: + update_message(msg_id, display) + last_text = display + if done: + _send_generated_files(open_id, raw) + break + if not user_tasks.get(open_id, {}).get("running", True): + send_message(open_id, "⏹️ 已停止") except Exception as e: - import traceback; print(f"[ERROR] run_agent 异常: {e}"); traceback.print_exc() + import traceback + + print(f"[ERROR] run_agent 异常: {e}") + traceback.print_exc() send_message(open_id, f"❌ 错误: {str(e)}") - finally: user_tasks.pop(open_id, None) + finally: + user_tasks.pop(open_id, None) + threading.Thread(target=run_agent, daemon=True).start() + + def handle_command(open_id, cmd): - import glob if cmd == "/stop": - if open_id in user_tasks: user_tasks[open_id]['running'] = False - agent.abort(); send_message(open_id, "⏹️ 正在停止...") + if open_id in user_tasks: + user_tasks[open_id]["running"] = False + agent.abort() + send_message(open_id, "⏹️ 正在停止...") + elif cmd == "/new": + agent.abort() + agent.history = [] + send_message(open_id, "🆕 已清空当前共享上下文") elif cmd == "/help": send_message(open_id, "📖 命令列表:\n/stop - 停止当前任务\n/status - 查看状态\n/restore - 恢复上次对话历史\n/new - 开启新对话\n/help - 显示帮助") elif cmd == "/status": send_message(open_id, f"状态: {'🟢 空闲' if not agent.is_running else '🔴 运行中'}") elif cmd == "/restore": try: - files = glob.glob('./temp/model_responses_*.txt') - if not files: return send_message(open_id, "❌ 没有找到历史记录") + files = glob.glob("./temp/model_responses_*.txt") + if not files: + return send_message(open_id, "❌ 没有找到历史记录") latest = max(files, key=os.path.getmtime) - with open(latest, 'r', encoding='utf-8') as f: content = f.read() - users = re.findall(r'=== USER ===\n(.+?)(?==== |$)', content, re.DOTALL) - resps = re.findall(r'=== Response ===.*?\n(.+?)(?==== Prompt|$)', content, re.DOTALL) + with open(latest, "r", encoding="utf-8") as f: + content = f.read() + users = re.findall(r"=== USER ===\n(.+?)(?==== |$)", content, re.DOTALL) + resps = re.findall(r"=== Response ===.*?\n(.+?)(?==== Prompt|$)", content, re.DOTALL) count = 0 for u, r in zip(users, resps): u, r = u.strip(), r.strip()[:500] - if u and r: agent.history.extend([f"[USER]: {u}", f"[Agent] {r}"]); count += 1 + if u and r: + agent.history.extend([f"[USER]: {u}", f"[Agent] {r}"]) + count += 1 agent.abort() send_message(open_id, f"✅ 已恢复 {count} 轮对话\n来源: {os.path.basename(latest)}\n(仅恢复上下文,请输入新问题继续)") - except Exception as e: send_message(open_id, f"❌ 恢复失败: {e}") - else: send_message(open_id, f"❓ 未知命令: {cmd}") + except Exception as e: + send_message(open_id, f"❌ 恢复失败: {e}") + else: + send_message(open_id, f"❓ 未知命令: {cmd}") + + def main(): global client - if not APP_ID or not APP_SECRET: print("错误: 请在 mykey.py 中配置 fs_app_id 和 fs_app_secret"); sys.exit(1) + if not APP_ID or not APP_SECRET: + print("错误: 请在 mykey.py 或 mykey.json 中配置 fs_app_id 和 fs_app_secret") + sys.exit(1) client = create_client() handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(handle_message).build() cli = lark.ws.Client(APP_ID, APP_SECRET, event_handler=handler, log_level=lark.LogLevel.INFO) print("=" * 50 + "\n飞书 Agent 已启动(长连接模式)\n" + f"App ID: {APP_ID}\n等待消息...\n" + "=" * 50) cli.start() -if __name__ == "__main__": main() \ No newline at end of file + + +if __name__ == "__main__": + main() diff --git a/launch.pyw b/launch.pyw index 78d4894..845f229 100644 --- a/launch.pyw +++ b/launch.pyw @@ -65,6 +65,10 @@ if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('port', nargs='?', default='0'); parser.add_argument('--tg', action='store_true', help='启动 Telegram Bot'); + parser.add_argument('--qq', action='store_true', help='启动 QQ Bot'); + parser.add_argument('--feishu', '--fs', dest='feishu', action='store_true', help='启动 Feishu Bot'); + parser.add_argument('--wecom', action='store_true', help='启动 WeCom Bot'); + parser.add_argument('--dingtalk', '--dt', dest='dingtalk', action='store_true', help='启动 DingTalk Bot'); parser.add_argument('--no-sched', action='store_true', help='不启动计划任务调度器') parser.add_argument('--llm_no', type=int, default=0, help='LLM编号') args = parser.parse_args() @@ -78,6 +82,34 @@ if __name__ == '__main__': atexit.register(tgproc.kill) print('[Launch] Telegram Bot started') else: print('[Launch] Telegram Bot not enabled (use --tg to start)') + + if args.qq: + script_dir = os.path.dirname(os.path.abspath(__file__)) + qqproc = subprocess.Popen([sys.executable, os.path.join(script_dir, "qqapp.py")], creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0) + atexit.register(qqproc.kill) + print('[Launch] QQ Bot started') + else: print('[Launch] QQ Bot not enabled (use --qq to start)') + + if args.feishu: + script_dir = os.path.dirname(os.path.abspath(__file__)) + fsproc = subprocess.Popen([sys.executable, os.path.join(script_dir, "fsapp.py")], creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0) + atexit.register(fsproc.kill) + print('[Launch] Feishu Bot started') + else: print('[Launch] Feishu Bot not enabled (use --feishu to start)') + + if args.wecom: + script_dir = os.path.dirname(os.path.abspath(__file__)) + wcproc = subprocess.Popen([sys.executable, os.path.join(script_dir, "wecomapp.py")], creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0) + atexit.register(wcproc.kill) + print('[Launch] WeCom Bot started') + else: print('[Launch] WeCom Bot not enabled (use --wecom to start)') + + if args.dingtalk: + script_dir = os.path.dirname(os.path.abspath(__file__)) + dtproc = subprocess.Popen([sys.executable, os.path.join(script_dir, "dingtalkapp.py")], creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0) + atexit.register(dtproc.kill) + print('[Launch] DingTalk Bot started') + else: print('[Launch] DingTalk Bot not enabled (use --dingtalk to start)') if not args.no_sched: try: @@ -101,4 +133,4 @@ if __name__ == '__main__': title='GenericAgent', url=f'http://localhost:{port}', width=WINDOW_WIDTH, height=WINDOW_HEIGHT, x=x_pos, y=TOP_PADDING, resizable=True, text_select=True) - webview.start() \ No newline at end of file + webview.start() diff --git a/llmcore.py b/llmcore.py index cb9ea89..c7ac438 100644 --- a/llmcore.py +++ b/llmcore.py @@ -1,4 +1,4 @@ -import os, json, re, time, requests, sys, threading, urllib3 +import os, json, re, time, requests, sys, threading, urllib3, base64, mimetypes from datetime import datetime urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -34,6 +34,25 @@ def auto_make_url(base, path): if b.endswith('$'): return b[:-1].rstrip('/') return b if b.endswith(p) else f"{b}/{p}" if re.search(r'/v\d+$', b) else f"{b}/v1/{p}" +def build_multimodal_content(prompt_text, image_paths): + parts = [] + text = prompt_text if isinstance(prompt_text, str) else str(prompt_text or "") + if text.strip(): + parts.append({"type": "text", "text": text}) + else: + parts.append({"type": "text", "text": "请查看图片并理解用户意图。"}) + for path in image_paths or []: + if not path or not os.path.isfile(path): continue + try: + mime = mimetypes.guess_type(path)[0] or "image/png" + if not mime.startswith("image/"): mime = "image/png" + with open(path, "rb") as f: + data_url = f"data:{mime};base64,{base64.b64encode(f.read()).decode('ascii')}" + parts.append({"type": "image_url", "image_url": {"url": data_url}}) + except Exception as e: + print(f"[WARN] encode image failed {path}: {e}") + return parts + class SiderLLMSession: def __init__(self, sider_cookie, default_model="gemini-3.0-flash"): from sider_ai_api import Session # 不使用sider的话没必要安装这个包 @@ -392,6 +411,8 @@ class ToolClient: self.total_cd_tokens = 0 def chat(self, messages, tools=None): + if self._should_use_structured_messages(messages): + return (yield from self._chat_structured(messages, tools)) full_prompt = self._build_protocol_prompt(messages, tools) print("Full prompt length:", len(full_prompt), 'chars') script_dir = os.path.dirname(os.path.abspath(__file__)) @@ -410,14 +431,27 @@ class ToolClient: f.write(f"=== Response === {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n{raw_text}\n\n") return self._parse_mixed_response(raw_text) - def _build_protocol_prompt(self, messages, tools): - system_content = next((m['content'] for m in messages if m['role'].lower() == 'system'), "") - history_msgs = [m for m in messages if m['role'].lower() != 'system'] - # 构造工具描述 + def _should_use_structured_messages(self, messages): + return isinstance(self.backend, LLMSession) and any(isinstance(m.get("content"), list) for m in messages) + + def _estimate_content_len(self, content): + if isinstance(content, str): return len(content) + if isinstance(content, list): + total = 0 + for part in content: + if not isinstance(part, dict): continue + if part.get("type") == "text": + total += len(part.get("text", "")) + elif part.get("type") == "image_url": + total += 1000 + return total + return len(str(content)) + + def _prepare_tool_instruction(self, tools): tool_instruction = "" - if tools: - tools_json = json.dumps(tools, ensure_ascii=False, separators=(',', ':')) - tool_instruction = f""" + if not tools: return tool_instruction + tools_json = json.dumps(tools, ensure_ascii=False, separators=(',', ':')) + tool_instruction = f""" ### 交互协议 (必须严格遵守,持续有效) 请按照以下步骤思考并行动,标签之间需要回车换行: 1. **思考**: 在 `` 标签中先进行思考,分析现状和策略。 @@ -428,11 +462,70 @@ class ToolClient: ### 可用工具库(已挂载,持续有效) {tools_json} """ - if self.auto_save_tokens and self.last_tools == tools_json: - tool_instruction = "\n### 工具库状态:持续有效(code_run/file_read等),**可正常调用**。调用协议沿用。\n" + if self.auto_save_tokens and self.last_tools == tools_json: + tool_instruction = "\n### 工具库状态:持续有效(code_run/file_read等),**可正常调用**。调用协议沿用。\n" + else: + self.total_cd_tokens = 0 + self.last_tools = tools_json + return tool_instruction + + def _build_backend_messages(self, messages, tools): + system_content = next((m['content'] for m in messages if m['role'].lower() == 'system'), "") + history_msgs = [m for m in messages if m['role'].lower() != 'system'] + tool_instruction = self._prepare_tool_instruction(tools) + backend_messages = [] + merged_system = f"{system_content}\n{tool_instruction}".strip() if tool_instruction else system_content + if merged_system: + backend_messages.append({"role": "system", "content": merged_system}) + for m in history_msgs: + backend_messages.append({"role": m['role'], "content": m['content']}) + self.total_cd_tokens += self._estimate_content_len(m['content']) + if self.total_cd_tokens > 6000: self.last_tools = '' + return backend_messages + + def _serialize_messages_for_log(self, messages): + logged = [] + for msg in messages: + content = msg.get("content") + if isinstance(content, list): + parts = [] + for part in content: + if not isinstance(part, dict): continue + if part.get("type") == "text": + parts.append({"type": "text", "text": part.get("text", "")}) + elif part.get("type") == "image_url": + url = (part.get("image_url") or {}).get("url", "") + prefix = url.split(",", 1)[0] if url else "data:image/unknown;base64" + parts.append({"type": "image_url", "image_url": {"url": prefix + ","}}) + else: + parts.append(part) + logged.append({"role": msg.get("role"), "content": parts}) else: - self.total_cd_tokens = 0 - self.last_tools = tools_json + logged.append(msg) + return json.dumps(logged, ensure_ascii=False, indent=2) + + def _chat_structured(self, messages, tools): + backend_messages = self._build_backend_messages(messages, tools) + print("Structured prompt length:", sum(self._estimate_content_len(m.get("content")) for m in backend_messages), 'chars') + script_dir = os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(script_dir, f'./temp/model_responses_{os.getpid()}.txt'), 'a', encoding='utf-8', errors="replace") as f: + f.write(f"=== Prompt === {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n{self._serialize_messages_for_log(backend_messages)}\n") + gen = self.backend.raw_ask(backend_messages) + raw_text = ''; summarytag = '[NextWillSummary]' + for chunk in gen: + raw_text += chunk + if chunk != summarytag: yield chunk + print('Complete response received.') + if raw_text.endswith(summarytag): + self.last_tools = ''; raw_text = raw_text[:-len(summarytag)] + with open(os.path.join(script_dir, f'./temp/model_responses_{os.getpid()}.txt'), 'a', encoding='utf-8', errors="replace") as f: + f.write(f"=== Response === {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n{raw_text}\n\n") + return self._parse_mixed_response(raw_text) + + def _build_protocol_prompt(self, messages, tools): + system_content = next((m['content'] for m in messages if m['role'].lower() == 'system'), "") + history_msgs = [m for m in messages if m['role'].lower() != 'system'] + tool_instruction = self._prepare_tool_instruction(tools) prompt = "" if system_content: prompt += f"=== SYSTEM ===\n{system_content}\n" @@ -440,7 +533,7 @@ class ToolClient: for m in history_msgs: role = "USER" if m['role'] == 'user' else "ASSISTANT" prompt += f"=== {role} ===\n{m['content']}\n\n" - self.total_cd_tokens += len(m['content']) + self.total_cd_tokens += self._estimate_content_len(m['content']) if self.total_cd_tokens > 6000: self.last_tools = '' @@ -538,4 +631,4 @@ if __name__ == "__main__": response = get_final(llmclient.chat( messages=[{"role": "user", "content": "10.176.45.12"}] )) - print(response.content) \ No newline at end of file + print(response.content) diff --git a/mykey_template.py b/mykey_template.py index b299a81..bf7c34f 100644 --- a/mykey_template.py +++ b/mykey_template.py @@ -30,5 +30,18 @@ claude_config = { # If you need them # tg_bot_token = '84102K2gYZ...' # tg_allowed_users = [6806...] +# qq_app_id = '123456789' +# qq_app_secret = 'xxxxxxxxxxxxxxxx' +# qq_allowed_users = ['your_user_openid'] # 留空或 ['*'] 表示允许所有 QQ 用户 +# fs_app_id = 'cli_xxxxxxxxxxxxxxxx' +# fs_app_secret = 'xxxxxxxxxxxxxxxx' +# fs_allowed_users = ['ou_xxxxxxxxxxxxxxxx'] # 留空或 ['*'] 表示允许所有飞书用户 +# wecom_bot_id = 'your_bot_id' +# wecom_secret = 'your_bot_secret' +# wecom_allowed_users = ['your_user_id'] # 留空或 ['*'] 表示允许所有企业微信用户 +# wecom_welcome_message = '你好,我在线上。' +# dingtalk_client_id = 'your_app_key' +# dingtalk_client_secret = 'your_app_secret' +# dingtalk_allowed_users = ['your_staff_id'] # 留空或 ['*'] 表示允许所有钉钉用户 # proxy = "http://127.0.0.1:2082" diff --git a/qqapp.py b/qqapp.py new file mode 100644 index 0000000..5e65002 --- /dev/null +++ b/qqapp.py @@ -0,0 +1,307 @@ +import os, sys, re, threading, asyncio, queue as Q, socket, time, glob +from collections import deque + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from agentmain import GeneraticAgent +from llmcore import mykeys + +try: + import botpy + from botpy.message import C2CMessage, GroupMessage +except Exception: + print("Please install qq-botpy to use QQ module: pip install qq-botpy") + sys.exit(1) + +agent = GeneraticAgent() +agent.verbose = False + +APP_ID = str(mykeys.get("qq_app_id", "") or "").strip() +APP_SECRET = str(mykeys.get("qq_app_secret", "") or "").strip() +ALLOWED = {str(x).strip() for x in mykeys.get("qq_allowed_users", []) if str(x).strip()} + +_TAG_PATS = [r"<" + t + r">.*?" for t in ("thinking", "summary", "tool_use", "file_content")] +_PROCESSED_IDS = deque(maxlen=1000) +_USER_TASKS = {} +_SEQ_LOCK = threading.Lock() +_MSG_SEQ = 1 + + +def _next_msg_seq(): + global _MSG_SEQ + with _SEQ_LOCK: + _MSG_SEQ += 1 + return _MSG_SEQ + + +def _clean(text): + for pat in _TAG_PATS: + text = re.sub(pat, "", text, flags=re.DOTALL) + return re.sub(r"\n{3,}", "\n\n", text).strip() or "..." + + +def _extract_files(text): + return re.findall(r"\[FILE:([^\]]+)\]", text or "") + + +def _strip_files(text): + return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip() + + +def _split_text(text, limit=1500): + text = (text or "").strip() or "..." + parts = [] + while len(text) > limit: + cut = text.rfind("\n", 0, limit) + if cut < limit * 0.6: + cut = limit + parts.append(text[:cut].rstrip()) + text = text[cut:].lstrip() + if text: + parts.append(text) + return parts or ["..."] + + +def _format_restore(): + files = glob.glob("./temp/model_responses_*.txt") + if not files: + return None, "❌ 没有找到历史记录" + latest = max(files, key=os.path.getmtime) + with open(latest, "r", encoding="utf-8") as f: + content = f.read() + users = re.findall(r"=== USER ===\n(.+?)(?==== |$)", content, re.DOTALL) + resps = re.findall(r"=== Response ===.*?\n(.+?)(?==== Prompt|$)", content, re.DOTALL) + count, restored = 0, [] + for u, r in zip(users, resps): + u, r = u.strip(), r.strip()[:500] + if u and r: + restored.extend([f"[USER]: {u}", f"[Agent] {r}"]) + count += 1 + if not restored: + return None, "❌ 历史记录里没有可恢复内容" + return (restored, os.path.basename(latest), count), None + + +def _build_intents(): + try: + return botpy.Intents(public_messages=True, direct_message=True) + except Exception: + intents = botpy.Intents.none() if hasattr(botpy.Intents, "none") else botpy.Intents() + for attr in ( + "public_messages", + "public_guild_messages", + "direct_message", + "direct_messages", + "c2c_message", + "c2c_messages", + "group_at_message", + "group_at_messages", + ): + if hasattr(intents, attr): + try: + setattr(intents, attr, True) + except Exception: + pass + return intents + + +def _make_bot_class(app): + intents = _build_intents() + + class _QQBot(botpy.Client): + def __init__(self): + super().__init__(intents=intents, ext_handlers=False) + + async def on_ready(self): + name = getattr(getattr(self, "robot", None), "name", "QQBot") + print(f"[QQ] bot ready: {name}") + + async def on_c2c_message_create(self, message: C2CMessage): + await app.on_message(message, is_group=False) + + async def on_group_at_message_create(self, message: GroupMessage): + await app.on_message(message, is_group=True) + + async def on_direct_message_create(self, message): + await app.on_message(message, is_group=False) + + return _QQBot + + +class QQApp: + def __init__(self): + self.client = None + + async def send_text(self, chat_id, content, *, msg_id=None, is_group=False): + if not self.client: + return + for part in _split_text(content): + seq = _next_msg_seq() + if is_group: + await self.client.api.post_group_message( + group_openid=chat_id, + msg_type=0, + content=part, + msg_id=msg_id, + msg_seq=seq, + ) + else: + await self.client.api.post_c2c_message( + openid=chat_id, + msg_type=0, + content=part, + msg_id=msg_id, + msg_seq=seq, + ) + + async def send_done(self, chat_id, raw_text, *, msg_id=None, is_group=False): + files = [p for p in _extract_files(raw_text) if os.path.exists(p)] + body = _strip_files(_clean(raw_text)) + if files: + body = (body + "\n\n" if body else "") + "\n".join([f"生成文件: {p}" for p in files]) + await self.send_text(chat_id, body or "...", msg_id=msg_id, is_group=is_group) + + async def handle_command(self, chat_id, cmd, *, msg_id=None, is_group=False): + parts = (cmd or "").split() + op = (parts[0] if parts else "").lower() + if op == "/stop": + state = _USER_TASKS.get(chat_id) + if state: + state["running"] = False + agent.abort() + await self.send_text(chat_id, "⏹️ 正在停止...", msg_id=msg_id, is_group=is_group) + elif op == "/status": + llm = agent.get_llm_name() if agent.llmclient else "未配置" + await self.send_text(chat_id, f"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\nLLM: [{agent.llm_no}] {llm}", msg_id=msg_id, is_group=is_group) + elif op == "/llm": + if not agent.llmclient: + return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置", msg_id=msg_id, is_group=is_group) + if len(parts) > 1: + try: + n = int(parts[1]) + agent.next_llm(n) + await self.send_text(chat_id, f"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}", msg_id=msg_id, is_group=is_group) + except Exception: + await self.send_text(chat_id, f"用法: /llm <0-{len(agent.list_llms()) - 1}>", msg_id=msg_id, is_group=is_group) + else: + lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in agent.list_llms()] + await self.send_text(chat_id, "LLMs:\n" + "\n".join(lines), msg_id=msg_id, is_group=is_group) + elif op == "/restore": + try: + restored_info, err = _format_restore() + if err: + return await self.send_text(chat_id, err, msg_id=msg_id, is_group=is_group) + restored, fname, count = restored_info + agent.abort() + agent.history.extend(restored) + await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", msg_id=msg_id, is_group=is_group) + except Exception as e: + await self.send_text(chat_id, f"❌ 恢复失败: {e}", msg_id=msg_id, is_group=is_group) + elif op == "/new": + agent.abort() + agent.history = [] + await self.send_text(chat_id, "🆕 已清空当前共享上下文", msg_id=msg_id, is_group=is_group) + else: + await self.send_text( + chat_id, + "📖 命令列表:\n/help - 显示帮助\n/status - 查看状态\n/stop - 停止当前任务\n/new - 清空当前上下文\n/restore - 恢复上次对话历史\n/llm [n] - 查看或切换模型", + msg_id=msg_id, + is_group=is_group, + ) + + async def run_agent(self, chat_id, text, *, msg_id=None, is_group=False): + state = {"running": True} + _USER_TASKS[chat_id] = state + try: + await self.send_text(chat_id, "思考中...", msg_id=msg_id, is_group=is_group) + prompt = f"If you need to show files to user, use [FILE:filepath] in your response.\n\n{text}" + dq = agent.put_task(prompt, source="qq") + last_ping = time.time() + while state["running"]: + try: + item = await asyncio.to_thread(dq.get, True, 3) + except Q.Empty: + if agent.is_running and time.time() - last_ping > 20: + await self.send_text(chat_id, "⏳ 还在处理中,请稍等...", msg_id=msg_id, is_group=is_group) + last_ping = time.time() + continue + if "done" in item: + await self.send_done(chat_id, item.get("done", ""), msg_id=msg_id, is_group=is_group) + break + if not state["running"]: + await self.send_text(chat_id, "⏹️ 已停止", msg_id=msg_id, is_group=is_group) + except Exception as e: + import traceback + + print(f"[QQ] run_agent error: {e}") + traceback.print_exc() + await self.send_text(chat_id, f"❌ 错误: {e}", msg_id=msg_id, is_group=is_group) + finally: + _USER_TASKS.pop(chat_id, None) + + async def on_message(self, data, is_group=False): + try: + msg_id = getattr(data, "id", None) + if msg_id in _PROCESSED_IDS: + return + _PROCESSED_IDS.append(msg_id) + content = (getattr(data, "content", "") or "").strip() + if not content: + return + author = getattr(data, "author", None) + if is_group: + chat_id = str(getattr(data, "group_openid", "") or "") + user_id = str(getattr(author, "member_openid", "") or getattr(author, "id", "") or "unknown") + else: + user_id = str(getattr(author, "user_openid", "") or getattr(author, "id", "") or "unknown") + chat_id = user_id + public_access = not ALLOWED or "*" in ALLOWED + if not public_access and user_id not in ALLOWED: + print(f"[QQ] unauthorized user: {user_id}") + return + print(f"[QQ] message from {user_id} ({'group' if is_group else 'c2c'}): {content}") + if content.startswith("/"): + await self.handle_command(chat_id, content, msg_id=msg_id, is_group=is_group) + return + asyncio.create_task(self.run_agent(chat_id, content, msg_id=msg_id, is_group=is_group)) + except Exception: + import traceback + + print("[QQ] handle_message error") + traceback.print_exc() + + async def start(self): + BotClass = _make_bot_class(self) + self.client = BotClass() + while True: + try: + print(f"[QQ] bot starting... {time.strftime('%m-%d %H:%M')}") + await self.client.start(appid=APP_ID, secret=APP_SECRET) + except Exception as e: + print(f"[QQ] bot error: {e}") + print("[QQ] reconnect in 5s...") + await asyncio.sleep(5) + + +if __name__ == "__main__": + try: + _lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + _lock_sock.bind(("127.0.0.1", 19528)) + except OSError: + print("[QQ] Another instance is already running, skipping...") + sys.exit(1) + + if not APP_ID or not APP_SECRET: + print("[QQ] ERROR: please set qq_app_id and qq_app_secret in mykey.py or mykey.json") + sys.exit(1) + if agent.llmclient is None: + print("[QQ] ERROR: no usable LLM backend found in mykey.py or mykey.json") + sys.exit(1) + + log_dir = os.path.join(os.path.dirname(__file__), "temp") + os.makedirs(log_dir, exist_ok=True) + _logf = open(os.path.join(log_dir, "qqapp.log"), "a", encoding="utf-8", buffering=1) + sys.stdout = sys.stderr = _logf + print("[NEW] QQ process starting, the above are history infos ...") + print(f"[QQ] allow list: {'public' if not ALLOWED or '*' in ALLOWED else sorted(ALLOWED)}") + threading.Thread(target=agent.run, daemon=True).start() + asyncio.run(QQApp().start()) diff --git a/wecomapp.py b/wecomapp.py new file mode 100644 index 0000000..b5ef37b --- /dev/null +++ b/wecomapp.py @@ -0,0 +1,274 @@ +import os, sys, re, threading, asyncio, queue as Q, socket, time, glob +from collections import deque + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from agentmain import GeneraticAgent +from llmcore import mykeys + +try: + from wecom_aibot_sdk import WSClient, generate_req_id +except Exception: + print("Please install wecom_aibot_sdk to use WeCom: pip install wecom_aibot_sdk") + sys.exit(1) + +agent = GeneraticAgent() +agent.verbose = False + +BOT_ID = str(mykeys.get("wecom_bot_id", "") or "").strip() +SECRET = str(mykeys.get("wecom_secret", "") or "").strip() +WELCOME = str(mykeys.get("wecom_welcome_message", "") or "").strip() +ALLOWED = {str(x).strip() for x in mykeys.get("wecom_allowed_users", []) if str(x).strip()} + +_TAG_PATS = [r"<" + t + r">.*?" for t in ("thinking", "summary", "tool_use", "file_content")] +_PROCESSED_IDS = deque(maxlen=1000) +_USER_TASKS = {} + + +def _clean(text): + for pat in _TAG_PATS: + text = re.sub(pat, "", text, flags=re.DOTALL) + return re.sub(r"\n{3,}", "\n\n", text).strip() or "..." + + +def _extract_files(text): + return re.findall(r"\[FILE:([^\]]+)\]", text or "") + + +def _strip_files(text): + return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip() + + +def _split_text(text, limit=1200): + text = (text or "").strip() or "..." + parts = [] + while len(text) > limit: + cut = text.rfind("\n", 0, limit) + if cut < limit * 0.6: + cut = limit + parts.append(text[:cut].rstrip()) + text = text[cut:].lstrip() + if text: + parts.append(text) + return parts or ["..."] + + +def _format_restore(): + files = glob.glob("./temp/model_responses_*.txt") + if not files: + return None, "❌ 没有找到历史记录" + latest = max(files, key=os.path.getmtime) + with open(latest, "r", encoding="utf-8") as f: + content = f.read() + users = re.findall(r"=== USER ===\n(.+?)(?==== |$)", content, re.DOTALL) + resps = re.findall(r"=== Response ===.*?\n(.+?)(?==== Prompt|$)", content, re.DOTALL) + count, restored = 0, [] + for u, r in zip(users, resps): + u, r = u.strip(), r.strip()[:500] + if u and r: + restored.extend([f"[USER]: {u}", f"[Agent] {r}"]) + count += 1 + if not restored: + return None, "❌ 历史记录里没有可恢复内容" + return (restored, os.path.basename(latest), count), None + + +class WeComApp: + def __init__(self): + self.client = None + self.chat_frames = {} + + def _body(self, frame): + if hasattr(frame, "body"): + return frame.body or {} + if isinstance(frame, dict): + return frame.get("body", frame) + return {} + + async def send_text(self, chat_id, content): + if not self.client: + return + frame = self.chat_frames.get(chat_id) + if not frame: + print(f"[WeCom] no frame found for chat: {chat_id}") + return + for part in _split_text(content): + stream_id = generate_req_id("stream") + await self.client.reply_stream(frame, stream_id, part, finish=True) + + async def send_done(self, chat_id, raw_text): + files = [p for p in _extract_files(raw_text) if os.path.exists(p)] + body = _strip_files(_clean(raw_text)) + if files: + body = (body + "\n\n" if body else "") + "\n".join([f"生成文件: {p}" for p in files]) + await self.send_text(chat_id, body or "...") + + async def handle_command(self, chat_id, cmd): + parts = (cmd or "").split() + op = (parts[0] if parts else "").lower() + if op == "/stop": + state = _USER_TASKS.get(chat_id) + if state: + state["running"] = False + agent.abort() + await self.send_text(chat_id, "⏹️ 正在停止...") + elif op == "/status": + llm = agent.get_llm_name() if agent.llmclient else "未配置" + await self.send_text(chat_id, f"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\nLLM: [{agent.llm_no}] {llm}") + elif op == "/llm": + if not agent.llmclient: + return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置") + if len(parts) > 1: + try: + n = int(parts[1]) + agent.next_llm(n) + await self.send_text(chat_id, f"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}") + except Exception: + await self.send_text(chat_id, f"用法: /llm <0-{len(agent.list_llms()) - 1}>") + else: + lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in agent.list_llms()] + await self.send_text(chat_id, "LLMs:\n" + "\n".join(lines)) + elif op == "/restore": + try: + restored_info, err = _format_restore() + if err: + return await self.send_text(chat_id, err) + restored, fname, count = restored_info + agent.abort() + agent.history.extend(restored) + await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)") + except Exception as e: + await self.send_text(chat_id, f"❌ 恢复失败: {e}") + elif op == "/new": + agent.abort() + agent.history = [] + await self.send_text(chat_id, "🆕 已清空当前共享上下文") + else: + await self.send_text( + chat_id, + "📖 命令列表:\n/help - 显示帮助\n/status - 查看状态\n/stop - 停止当前任务\n/new - 清空当前上下文\n/restore - 恢复上次对话历史\n/llm [n] - 查看或切换模型", + ) + + async def run_agent(self, chat_id, text): + state = {"running": True} + _USER_TASKS[chat_id] = state + try: + await self.send_text(chat_id, "思考中...") + prompt = f"If you need to show files to user, use [FILE:filepath] in your response.\n\n{text}" + dq = agent.put_task(prompt, source="wecom") + last_ping = time.time() + while state["running"]: + try: + item = await asyncio.to_thread(dq.get, True, 3) + except Q.Empty: + if agent.is_running and time.time() - last_ping > 20: + await self.send_text(chat_id, "⏳ 还在处理中,请稍等...") + last_ping = time.time() + continue + if "done" in item: + await self.send_done(chat_id, item.get("done", "")) + break + if not state["running"]: + await self.send_text(chat_id, "⏹️ 已停止") + except Exception as e: + import traceback + + print(f"[WeCom] run_agent error: {e}") + traceback.print_exc() + await self.send_text(chat_id, f"❌ 错误: {e}") + finally: + _USER_TASKS.pop(chat_id, None) + + async def on_text(self, frame): + try: + body = self._body(frame) + if not isinstance(body, dict): + return + msg_id = body.get("msgid") or f"{body.get('chatid', '')}_{body.get('sendertime', '')}" + if msg_id in _PROCESSED_IDS: + return + _PROCESSED_IDS.append(msg_id) + from_info = body.get("from", {}) if isinstance(body.get("from", {}), dict) else {} + sender_id = str(from_info.get("userid", "") or "unknown") + chat_id = str(body.get("chatid", "") or sender_id) + content = str((body.get("text", {}) or {}).get("content", "") or "").strip() + if not content: + return + public_access = not ALLOWED or "*" in ALLOWED + if not public_access and sender_id not in ALLOWED: + print(f"[WeCom] unauthorized user: {sender_id}") + return + self.chat_frames[chat_id] = frame + print(f"[WeCom] message from {sender_id}: {content}") + if content.startswith("/"): + await self.handle_command(chat_id, content) + return + asyncio.create_task(self.run_agent(chat_id, content)) + except Exception: + import traceback + + print("[WeCom] handle_message error") + traceback.print_exc() + + async def on_enter_chat(self, frame): + if not WELCOME or not self.client: + return + try: + await self.client.reply_welcome(frame, {"msgtype": "text", "text": {"content": WELCOME}}) + except Exception as e: + print(f"[WeCom] welcome error: {e}") + + async def on_connected(self, frame): + print("[WeCom] connected") + + async def on_authenticated(self, frame): + print("[WeCom] authenticated") + + async def on_disconnected(self, frame): + print("[WeCom] disconnected") + + async def on_error(self, frame): + print(f"[WeCom] error: {frame}") + + async def start(self): + self.client = WSClient({ + "bot_id": BOT_ID, + "secret": SECRET, + "reconnect_interval": 1000, + "max_reconnect_attempts": -1, + "heartbeat_interval": 30000, + }) + self.client.on("connected", self.on_connected) + self.client.on("authenticated", self.on_authenticated) + self.client.on("disconnected", self.on_disconnected) + self.client.on("error", self.on_error) + self.client.on("message.text", self.on_text) + self.client.on("event.enter_chat", self.on_enter_chat) + print("[WeCom] bot starting...") + await self.client.connect_async() + while True: + await asyncio.sleep(1) + + +if __name__ == "__main__": + try: + _lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + _lock_sock.bind(("127.0.0.1", 19529)) + except OSError: + print("[WeCom] Another instance is already running, skipping...") + sys.exit(1) + + if not BOT_ID or not SECRET: + print("[WeCom] ERROR: please set wecom_bot_id and wecom_secret in mykey.py or mykey.json") + sys.exit(1) + if agent.llmclient is None: + print("[WeCom] ERROR: no usable LLM backend found in mykey.py or mykey.json") + sys.exit(1) + + log_dir = os.path.join(os.path.dirname(__file__), "temp") + os.makedirs(log_dir, exist_ok=True) + _logf = open(os.path.join(log_dir, "wecomapp.log"), "a", encoding="utf-8", buffering=1) + sys.stdout = sys.stderr = _logf + print("[NEW] WeCom process starting, the above are history infos ...") + print(f"[WeCom] allow list: {'public' if not ALLOWED or '*' in ALLOWED else sorted(ALLOWED)}") + threading.Thread(target=agent.run, daemon=True).start() + asyncio.run(WeComApp().start())