Add chat app adapters for QQ, Feishu, WeCom, and DingTalk
This commit is contained in:
196
README.md
196
README.md
@@ -78,6 +78,100 @@ cp mykey_template.py mykey.py
|
|||||||
python launch.pyw
|
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):
|
**Also runs on Android** — tested successfully on Termux with `python agentmain.py` (CLI frontend):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -142,6 +236,10 @@ The agent starts with 7 primitive tools. Through `code_run`, it can install pack
|
|||||||
**Interface** (talk to the agent):
|
**Interface** (talk to the agent):
|
||||||
- `stapp.py` — Streamlit web UI
|
- `stapp.py` — Streamlit web UI
|
||||||
- `tgapp.py` — Telegram bot interface
|
- `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
|
- `launch.pyw` — One-click launcher with floating window
|
||||||
|
|
||||||
**Infrastructure**:
|
**Infrastructure**:
|
||||||
@@ -230,6 +328,100 @@ python agentmain.py
|
|||||||
|
|
||||||
启动后告诉 Agent:"执行 web setup SOP 解锁浏览器工具"——剩下的它自己搞定。完整引导流程见 [WELCOME_NEW_USER.md](WELCOME_NEW_USER.md)。
|
启动后告诉 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 |
|
| | GenericAgent | OpenClaw | Claude Code |
|
||||||
@@ -261,6 +453,10 @@ Agent 拥有 7 个原子工具:`code_run`(执行任意代码)、`file_read
|
|||||||
**交互界面**:
|
**交互界面**:
|
||||||
- `stapp.py` — Streamlit Web UI
|
- `stapp.py` — Streamlit Web UI
|
||||||
- `tgapp.py` — Telegram 机器人
|
- `tgapp.py` — Telegram 机器人
|
||||||
|
- `fsapp.py` — 飞书机器人
|
||||||
|
- `qqapp.py` — QQ 机器人
|
||||||
|
- `wecomapp.py` — 企业微信机器人
|
||||||
|
- `dingtalkapp.py` — 钉钉机器人
|
||||||
- `launch.pyw` — 一键启动 + 悬浮窗
|
- `launch.pyw` — 一键启动 + 悬浮窗
|
||||||
|
|
||||||
**基础设施**:
|
**基础设施**:
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ def get_pretty_json(data):
|
|||||||
data["script"] = data["script"].replace("; ", ";\n ")
|
data["script"] = data["script"].replace("; ", ";\n ")
|
||||||
return json.dumps(data, indent=2, ensure_ascii=False).replace('\\n', '\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 = [
|
messages = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"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):
|
for turn in range(max_turns):
|
||||||
yield f"**LLM Running (Turn {turn+1}) ...**\n\n"
|
yield f"**LLM Running (Turn {turn+1}) ...**\n\n"
|
||||||
|
|||||||
16
agentmain.py
16
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')
|
elif hasattr(sys.stderr, 'reconfigure'): sys.stderr.reconfigure(errors='replace')
|
||||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
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 agent_loop import agent_runner_loop, StepOutcome, BaseHandler
|
||||||
from ga import GenericAgentHandler, smart_format, get_global_memory, format_error
|
from ga import GenericAgentHandler, smart_format, get_global_memory, format_error
|
||||||
|
|
||||||
@@ -83,16 +83,16 @@ class GeneraticAgent:
|
|||||||
if self.handler is not None:
|
if self.handler is not None:
|
||||||
self.handler.code_stop_signal.append(1)
|
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()
|
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
|
return display_queue
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
while True:
|
while True:
|
||||||
task = self.task_queue.get()
|
task = self.task_queue.get()
|
||||||
self.is_running = True
|
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)
|
rquery = smart_format(raw_query.replace('\n', ' '), max_str_len=200)
|
||||||
self.history.append(f"[USER]: {rquery}")
|
self.history.append(f"[USER]: {rquery}")
|
||||||
|
|
||||||
@@ -108,8 +108,14 @@ class GeneraticAgent:
|
|||||||
user_input = raw_query
|
user_input = raw_query
|
||||||
if source == 'feishu' and len(self.history) > 1: # 如果有历史记录且来自飞书,注入到首轮 user_input 中(支持/restore恢复上下文)
|
if source == 'feishu' and len(self.history) > 1: # 如果有历史记录且来自飞书,注入到首轮 user_input 中(支持/restore恢复上下文)
|
||||||
user_input = handler._get_anchor_prompt() + f"\n\n### 用户当前消息\n{raw_query}"
|
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,
|
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:
|
try:
|
||||||
full_resp = ""; last_pos = 0
|
full_resp = ""; last_pos = 0
|
||||||
for chunk in gen:
|
for chunk in gen:
|
||||||
|
|||||||
317
dingtalkapp.py
Normal file
317
dingtalkapp.py
Normal file
@@ -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">.*?</" + 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())
|
||||||
543
fsapp.py
543
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 queue as Q
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
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
|
import lark_oapi as lark
|
||||||
from lark_oapi.api.im.v1 import *
|
from lark_oapi.api.im.v1 import *
|
||||||
from lark_oapi.api.contact.v3 import *
|
|
||||||
from agentmain import GeneraticAgent
|
from agentmain import GeneraticAgent
|
||||||
import mykey
|
from llmcore import mykeys
|
||||||
_TAG_PATS = [r'<' + t + r'>.*?</' + t + r'>' for t in ('thinking', 'summary', 'tool_use', 'file_content')]
|
|
||||||
def _clean(t):
|
_TAG_PATS = [r"<" + t + r">.*?</" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")]
|
||||||
for p in _TAG_PATS: t = re.sub(p, '', t, flags=re.DOTALL)
|
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
|
||||||
return re.sub(r'\n{3,}', '\n\n', t).strip() or '...'
|
_AUDIO_EXTS = {".opus", ".mp3", ".wav", ".m4a", ".aac"}
|
||||||
APP_ID, APP_SECRET = getattr(mykey, 'fs_app_id', None), getattr(mykey, 'fs_app_secret', None)
|
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
|
||||||
ALLOWED_USERS = set(getattr(mykey, 'fs_allowed_users', []))
|
_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()
|
agent = GeneraticAgent()
|
||||||
threading.Thread(target=agent.run, daemon=True).start()
|
threading.Thread(target=agent.run, daemon=True).start()
|
||||||
client, user_tasks = None, {}
|
client, user_tasks = None, {}
|
||||||
|
|
||||||
|
|
||||||
def create_client():
|
def create_client():
|
||||||
return lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET).log_level(lark.LogLevel.INFO).build()
|
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):
|
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(
|
body = CreateMessageRequest.builder().receive_id_type("open_id").request_body(
|
||||||
CreateMessageRequestBody.builder().receive_id(open_id).msg_type(mt).content(ct).build()).build()
|
CreateMessageRequestBody.builder().receive_id(open_id).msg_type(real_type).content(payload).build()
|
||||||
r = client.im.v1.message.create(body)
|
).build()
|
||||||
return r.data.message_id if r.success() else (print(f"发送失败: {r.code}, {r.msg}"), None)[1]
|
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):
|
def update_message(message_id, content):
|
||||||
body = PatchMessageRequest.builder().message_id(message_id).request_body(
|
body = PatchMessageRequest.builder().message_id(message_id).request_body(
|
||||||
PatchMessageRequestBody.builder().content(_card(content)).build()).build()
|
PatchMessageRequestBody.builder().content(_card(content)).build()
|
||||||
r = client.im.v1.message.patch(body)
|
).build()
|
||||||
if not r.success(): print(f"[ERROR] update_message 失败: {r.code}, {r.msg}")
|
response = client.im.v1.message.patch(body)
|
||||||
return r.success()
|
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):
|
def handle_message(data):
|
||||||
event, message, sender = data.event, data.event.message, data.event.sender
|
event, message, sender = data.event, data.event.message, data.event.sender
|
||||||
open_id, msg_type = sender.sender_id.open_id, message.message_type
|
open_id = sender.sender_id.open_id
|
||||||
if ALLOWED_USERS and open_id not in ALLOWED_USERS: return print(f"未授权用户: {open_id}")
|
if not PUBLIC_ACCESS and open_id not in ALLOWED_USERS:
|
||||||
if msg_type != "text": return send_message(open_id, "⚠️ 目前只支持文本消息")
|
print(f"未授权用户: {open_id}")
|
||||||
text = json.loads(message.content).get("text", "").strip()
|
return
|
||||||
if not text: return
|
user_input, image_paths = _build_user_message(message)
|
||||||
print(f"收到消息 [{open_id}]: {text}")
|
if not user_input:
|
||||||
if text.startswith("/"): return handle_command(open_id, text)
|
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():
|
def run_agent():
|
||||||
user_tasks[open_id] = {'running': True}
|
user_tasks[open_id] = {"running": True}
|
||||||
try:
|
try:
|
||||||
msg_id, dq, last_text = send_message(open_id, "思考中...", use_card=True), agent.put_task(text, source='feishu'), ""
|
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):
|
while user_tasks.get(open_id, {}).get("running", False):
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
item = None
|
item = None
|
||||||
try:
|
try:
|
||||||
while True: item = dq.get_nowait()
|
while True:
|
||||||
except: pass
|
item = dq.get_nowait()
|
||||||
if item is None: continue
|
except Exception:
|
||||||
raw, done = item.get("done") or item.get("next", ""), "done" in item
|
pass
|
||||||
show = _clean(raw)
|
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:
|
if len(show) > 3500:
|
||||||
# 智能截断:避免切断代码块
|
|
||||||
cut = show[-3000:]
|
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
|
msg_id, last_text, show = send_message(open_id, "(继续...)", use_card=True), "", cut
|
||||||
display = show if done else show + " ⏳"
|
display = show if done else show + " ⏳"
|
||||||
if display != last_text and msg_id: update_message(msg_id, display); last_text = display
|
if display != last_text and msg_id:
|
||||||
if done: break
|
update_message(msg_id, display)
|
||||||
if not user_tasks.get(open_id, {}).get('running', True): send_message(open_id, "⏹️ 已停止")
|
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:
|
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)}")
|
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()
|
threading.Thread(target=run_agent, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def handle_command(open_id, cmd):
|
def handle_command(open_id, cmd):
|
||||||
import glob
|
|
||||||
if cmd == "/stop":
|
if cmd == "/stop":
|
||||||
if open_id in user_tasks: user_tasks[open_id]['running'] = False
|
if open_id in user_tasks:
|
||||||
agent.abort(); send_message(open_id, "⏹️ 正在停止...")
|
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":
|
elif cmd == "/help":
|
||||||
send_message(open_id, "📖 命令列表:\n/stop - 停止当前任务\n/status - 查看状态\n/restore - 恢复上次对话历史\n/new - 开启新对话\n/help - 显示帮助")
|
send_message(open_id, "📖 命令列表:\n/stop - 停止当前任务\n/status - 查看状态\n/restore - 恢复上次对话历史\n/new - 开启新对话\n/help - 显示帮助")
|
||||||
elif cmd == "/status":
|
elif cmd == "/status":
|
||||||
send_message(open_id, f"状态: {'🟢 空闲' if not agent.is_running else '🔴 运行中'}")
|
send_message(open_id, f"状态: {'🟢 空闲' if not agent.is_running else '🔴 运行中'}")
|
||||||
elif cmd == "/restore":
|
elif cmd == "/restore":
|
||||||
try:
|
try:
|
||||||
files = glob.glob('./temp/model_responses_*.txt')
|
files = glob.glob("./temp/model_responses_*.txt")
|
||||||
if not files: return send_message(open_id, "❌ 没有找到历史记录")
|
if not files:
|
||||||
|
return send_message(open_id, "❌ 没有找到历史记录")
|
||||||
latest = max(files, key=os.path.getmtime)
|
latest = max(files, key=os.path.getmtime)
|
||||||
with open(latest, 'r', encoding='utf-8') as f: content = f.read()
|
with open(latest, "r", encoding="utf-8") as f:
|
||||||
users = re.findall(r'=== USER ===\n(.+?)(?==== |$)', content, re.DOTALL)
|
content = f.read()
|
||||||
resps = re.findall(r'=== Response ===.*?\n(.+?)(?==== Prompt|$)', content, re.DOTALL)
|
users = re.findall(r"=== USER ===\n(.+?)(?==== |$)", content, re.DOTALL)
|
||||||
|
resps = re.findall(r"=== Response ===.*?\n(.+?)(?==== Prompt|$)", content, re.DOTALL)
|
||||||
count = 0
|
count = 0
|
||||||
for u, r in zip(users, resps):
|
for u, r in zip(users, resps):
|
||||||
u, r = u.strip(), r.strip()[:500]
|
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()
|
agent.abort()
|
||||||
send_message(open_id, f"✅ 已恢复 {count} 轮对话\n来源: {os.path.basename(latest)}\n(仅恢复上下文,请输入新问题继续)")
|
send_message(open_id, f"✅ 已恢复 {count} 轮对话\n来源: {os.path.basename(latest)}\n(仅恢复上下文,请输入新问题继续)")
|
||||||
except Exception as e: send_message(open_id, f"❌ 恢复失败: {e}")
|
except Exception as e:
|
||||||
else: send_message(open_id, f"❓ 未知命令: {cmd}")
|
send_message(open_id, f"❌ 恢复失败: {e}")
|
||||||
|
else:
|
||||||
|
send_message(open_id, f"❓ 未知命令: {cmd}")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
global client
|
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()
|
client = create_client()
|
||||||
handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(handle_message).build()
|
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)
|
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)
|
print("=" * 50 + "\n飞书 Agent 已启动(长连接模式)\n" + f"App ID: {APP_ID}\n等待消息...\n" + "=" * 50)
|
||||||
cli.start()
|
cli.start()
|
||||||
if __name__ == "__main__": main()
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
32
launch.pyw
32
launch.pyw
@@ -65,6 +65,10 @@ if __name__ == '__main__':
|
|||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument('port', nargs='?', default='0');
|
parser.add_argument('port', nargs='?', default='0');
|
||||||
parser.add_argument('--tg', action='store_true', help='启动 Telegram Bot');
|
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('--no-sched', action='store_true', help='不启动计划任务调度器')
|
||||||
parser.add_argument('--llm_no', type=int, default=0, help='LLM编号')
|
parser.add_argument('--llm_no', type=int, default=0, help='LLM编号')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -79,6 +83,34 @@ if __name__ == '__main__':
|
|||||||
print('[Launch] Telegram Bot started')
|
print('[Launch] Telegram Bot started')
|
||||||
else: print('[Launch] Telegram Bot not enabled (use --tg to start)')
|
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:
|
if not args.no_sched:
|
||||||
try:
|
try:
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM); sock.bind(('127.0.0.1', 45762)); sock.listen(1)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM); sock.bind(('127.0.0.1', 45762)); sock.listen(1)
|
||||||
|
|||||||
119
llmcore.py
119
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
|
from datetime import datetime
|
||||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
@@ -34,6 +34,25 @@ def auto_make_url(base, path):
|
|||||||
if b.endswith('$'): return b[:-1].rstrip('/')
|
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}"
|
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:
|
class SiderLLMSession:
|
||||||
def __init__(self, sider_cookie, default_model="gemini-3.0-flash"):
|
def __init__(self, sider_cookie, default_model="gemini-3.0-flash"):
|
||||||
from sider_ai_api import Session # 不使用sider的话没必要安装这个包
|
from sider_ai_api import Session # 不使用sider的话没必要安装这个包
|
||||||
@@ -392,6 +411,8 @@ class ToolClient:
|
|||||||
self.total_cd_tokens = 0
|
self.total_cd_tokens = 0
|
||||||
|
|
||||||
def chat(self, messages, tools=None):
|
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)
|
full_prompt = self._build_protocol_prompt(messages, tools)
|
||||||
print("Full prompt length:", len(full_prompt), 'chars')
|
print("Full prompt length:", len(full_prompt), 'chars')
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
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")
|
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)
|
return self._parse_mixed_response(raw_text)
|
||||||
|
|
||||||
def _build_protocol_prompt(self, messages, tools):
|
def _should_use_structured_messages(self, messages):
|
||||||
system_content = next((m['content'] for m in messages if m['role'].lower() == 'system'), "")
|
return isinstance(self.backend, LLMSession) and any(isinstance(m.get("content"), list) for m in messages)
|
||||||
history_msgs = [m for m in messages if m['role'].lower() != 'system']
|
|
||||||
# 构造工具描述
|
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 = ""
|
tool_instruction = ""
|
||||||
if tools:
|
if not tools: return tool_instruction
|
||||||
tools_json = json.dumps(tools, ensure_ascii=False, separators=(',', ':'))
|
tools_json = json.dumps(tools, ensure_ascii=False, separators=(',', ':'))
|
||||||
tool_instruction = f"""
|
tool_instruction = f"""
|
||||||
### 交互协议 (必须严格遵守,持续有效)
|
### 交互协议 (必须严格遵守,持续有效)
|
||||||
请按照以下步骤思考并行动,标签之间需要回车换行:
|
请按照以下步骤思考并行动,标签之间需要回车换行:
|
||||||
1. **思考**: 在 `<thinking>` 标签中先进行思考,分析现状和策略。
|
1. **思考**: 在 `<thinking>` 标签中先进行思考,分析现状和策略。
|
||||||
@@ -428,11 +462,70 @@ class ToolClient:
|
|||||||
### 可用工具库(已挂载,持续有效)
|
### 可用工具库(已挂载,持续有效)
|
||||||
{tools_json}
|
{tools_json}
|
||||||
"""
|
"""
|
||||||
if self.auto_save_tokens and self.last_tools == tools_json:
|
if self.auto_save_tokens and self.last_tools == tools_json:
|
||||||
tool_instruction = "\n### 工具库状态:持续有效(code_run/file_read等),**可正常调用**。调用协议沿用。\n"
|
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 + ",<omitted>"}})
|
||||||
|
else:
|
||||||
|
parts.append(part)
|
||||||
|
logged.append({"role": msg.get("role"), "content": parts})
|
||||||
else:
|
else:
|
||||||
self.total_cd_tokens = 0
|
logged.append(msg)
|
||||||
self.last_tools = tools_json
|
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 = ""
|
prompt = ""
|
||||||
if system_content: prompt += f"=== SYSTEM ===\n{system_content}\n"
|
if system_content: prompt += f"=== SYSTEM ===\n{system_content}\n"
|
||||||
@@ -440,7 +533,7 @@ class ToolClient:
|
|||||||
for m in history_msgs:
|
for m in history_msgs:
|
||||||
role = "USER" if m['role'] == 'user' else "ASSISTANT"
|
role = "USER" if m['role'] == 'user' else "ASSISTANT"
|
||||||
prompt += f"=== {role} ===\n{m['content']}\n\n"
|
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 = ''
|
if self.total_cd_tokens > 6000: self.last_tools = ''
|
||||||
|
|
||||||
|
|||||||
@@ -30,5 +30,18 @@ claude_config = {
|
|||||||
# If you need them
|
# If you need them
|
||||||
# tg_bot_token = '84102K2gYZ...'
|
# tg_bot_token = '84102K2gYZ...'
|
||||||
# tg_allowed_users = [6806...]
|
# 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"
|
# proxy = "http://127.0.0.1:2082"
|
||||||
|
|||||||
307
qqapp.py
Normal file
307
qqapp.py
Normal file
@@ -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">.*?</" + 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())
|
||||||
274
wecomapp.py
Normal file
274
wecomapp.py
Normal file
@@ -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">.*?</" + 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())
|
||||||
Reference in New Issue
Block a user