From a3e87f2f373a8be3d815ff9238c92787d93a088b Mon Sep 17 00:00:00 2001 From: rianli Date: Sun, 1 Feb 2026 17:10:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(qqbot):=20=E5=AE=9A=E6=97=B6=E6=8F=90?= =?UTF-8?q?=E9=86=92=E6=8A=80=E8=83=BD=E4=B8=8E=E4=B8=BB=E5=8A=A8=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **新增提醒技能** - 新增 skills/qqbot-cron/SKILL.md 定时提醒技能定义 - 支持一次性提醒(--at)和周期性提醒(--cron) - 支持设置、查询、取消提醒操作 **主动消息系统** - 新增 src/proactive.ts 主动消息发送模块 - 新增 src/known-users.ts 已知用户管理 - 新增 src/session-store.ts 会话存储 - 支持主动向用户/群组发送消息 **工具脚本** - 新增 scripts/proactive-api-server.ts 主动消息API服务 --- console.md | 1041 +++++++++++++++++++++++++++++++ openclaw.plugin.json | 8 + scripts/proactive-api-server.ts | 346 ++++++++++ scripts/send-proactive.ts | 273 ++++++++ skills/qqbot-cron/SKILL.md | 476 ++++++++++++++ src/api.ts | 244 +++++++- src/config.ts | 2 + src/gateway.ts | 450 +++++++++++-- src/known-users.ts | 358 +++++++++++ src/openclaw-plugin-sdk.d.ts | 483 ++++++++++++++ src/outbound.ts | 184 +++++- src/proactive.ts | 528 ++++++++++++++++ src/session-store.ts | 292 +++++++++ src/types.ts | 4 + upgrade-and-run.sh | 2 +- 15 files changed, 4639 insertions(+), 52 deletions(-) create mode 100644 console.md create mode 100644 scripts/proactive-api-server.ts create mode 100644 scripts/send-proactive.ts create mode 100644 skills/qqbot-cron/SKILL.md create mode 100644 src/known-users.ts create mode 100644 src/openclaw-plugin-sdk.d.ts create mode 100644 src/proactive.ts create mode 100644 src/session-store.ts diff --git a/console.md b/console.md new file mode 100644 index 0000000..fa74c12 --- /dev/null +++ b/console.md @@ -0,0 +1,1041 @@ +08:27:48 [qqbot] [qqbot:default] Heartbeat sent +08:27:48 [qqbot] [qqbot:default] Received op=11 t=undefined +08:27:48 [qqbot] [qqbot:default] Heartbeat ACK +08:27:50 [ws] ⇄ res ✓ node.list 1ms id=0404fd79…e7be +08:27:55 [ws] ⇄ res ✓ node.list 1ms id=84c095a4…be49 +08:28:00 [ws] ⇄ res ✓ node.list 2ms id=33639de8…7fc3 +08:28:02 [ws] → event health seq=9 clients=5 presenceVersion=6 healthVersion=8 +08:28:02 [ws] → event tick seq=10 clients=5 dropIfSlow=true +08:28:03 [session-store] Saved session for default: sessionId=dbae1ca2-e53c-48f9-8721-fb93e5356c77, lastSeq=2 +08:28:03 [qqbot] [qqbot:default] Received op=0 t=C2C_MESSAGE_CREATE +08:28:03 [known-users] Loaded 1 users from file +08:28:03 [known-users] Updated user 207A5B8339D01F6582911C014668B77B, interactions: 8 +08:28:03 [qqbot] [qqbot:default] Message enqueued, queue size: 1 +08:28:03 [qqbot] [qqbot:default] Processing message from 207A5B8339D01F6582911C014668B77B: 5分钟后提醒我喝水 +08:28:03 [qqbot] [qqbot:default] Stream enabled: true +08:28:03 [qqbot] [qqbot:default] Stream support: true (type=c2c, enabled=true) +08:28:03 [skills] plugin skill path not found (qqbot): /Users/lishoushuai/.openclaw/extensions/qqbot/qqbot-cron +08:28:03 [diagnostic] lane enqueue: lane=session:agent:main:main queueSize=1 +08:28:03 [diagnostic] lane dequeue: lane=session:agent:main:main waitMs=2 queueSize=0 +08:28:03 [diagnostic] lane enqueue: lane=main queueSize=1 +08:28:03 [diagnostic] lane dequeue: lane=main waitMs=1 queueSize=0 +08:28:03 [agent/embedded] embedded run start: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b sessionId=ba108bac-c99c-498f-b33f-06245ade1363 provider=qwen-portal model=coder-model thinking=off messageChannel=qqbot +08:28:03 [diagnostic] session state: sessionId=ba108bac-c99c-498f-b33f-06245ade1363 sessionKey=unknown prev=idle new=processing reason="run_started" queueDepth=0 +08:28:03 [diagnostic] run registered: sessionId=ba108bac-c99c-498f-b33f-06245ade1363 totalActive=1 +08:28:03 [agent/embedded] embedded run prompt start: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b sessionId=ba108bac-c99c-498f-b33f-06245ade1363 +08:28:03 [agent/embedded] embedded run agent start: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b +08:28:03 [ws] → event agent seq=11 clients=5 run=49d64fc4…e41b agent=main session=main stream=lifecycle aseq=1 phase=start +08:28:05 [ws] ⇄ res ✓ node.list 3ms id=15a727fe…d5a4 +08:28:08 [known-users] Saved 1 users to file +08:28:10 [ws] → event agent seq=12 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=2 text=我已经 +08:28:10 [ws] → event chat seq=13 clients=5 dropIfSlow=true +08:28:10 [qqbot] [qqbot:default] handlePartialReply: fullText.length=3, lastSentLength=0, streamBuffer.length=3, isNewSegment=false +08:28:10 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=3 +08:28:10 [qqbot] [qqbot:default] Stream started, max duration: 180s +08:28:10 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:28:10 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:28:10 [qqbot-api] >>> Body: { +"markdown": { +"content": "我已经" +}, +"msg_type": 2, +"msg_seq": 69934422, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 0 +} +} +08:28:10 [ws] → event agent seq=14 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=3 text=我已经为您 +08:28:10 [qqbot] [qqbot:default] handlePartialReply: fullText.length=5, lastSentLength=0, streamBuffer.length=5, isNewSegment=false +08:28:10 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=5 +08:28:10 [ws] → event agent seq=15 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=4 text=我已经为您设置了一个5分钟后 +08:28:10 [qqbot] [qqbot:default] handlePartialReply: fullText.length=14, lastSentLength=0, streamBuffer.length=14, isNewSegment=false +08:28:10 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=14 +08:28:10 [ws] → event agent seq=16 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=5 text=我已经为您设置了一个5分钟后提醒喝水的定时 +08:28:10 [qqbot] [qqbot:default] handlePartialReply: fullText.length=21, lastSentLength=0, streamBuffer.length=21, isNewSegment=false +08:28:10 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=21 +08:28:10 [ws] → event agent seq=17 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=6 text=我已经为您设置了一个5分钟后提醒喝水的定时任务。让我再次 +08:28:10 [ws] → event chat seq=18 clients=5 dropIfSlow=true +08:28:10 [qqbot] [qqbot:default] handlePartialReply: fullText.length=28, lastSentLength=0, streamBuffer.length=28, isNewSegment=false +08:28:10 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=28 +08:28:10 [ws] → event agent seq=19 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=7 text=我已经为您设置了一个5分钟后提醒喝水的定时任务。让我再次确认一下这个提醒 +08:28:10 [qqbot] [qqbot:default] handlePartialReply: fullText.length=36, lastSentLength=0, streamBuffer.length=36, isNewSegment=false +08:28:10 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=36 +08:28:10 [ws] → event agent seq=20 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=8 text=我已经为您设置了一个5分钟后提醒喝水的定时任务。让我再次确认一下这个提醒已经成功创建: +08:28:10 [ws] → event chat seq=21 clients=5 dropIfSlow=true +08:28:10 [qqbot] [qqbot:default] handlePartialReply: fullText.length=43, lastSentLength=0, streamBuffer.length=43, isNewSegment=false +08:28:10 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=43 +08:28:11 [ws] ⇄ res ✓ node.list 27ms id=be4d996c…f5de + +08:28:11 [agent/embedded] embedded run tool start: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b tool=exec toolCallId=call_45275505bcb24671aac9d040 +08:28:12 [qqbot-api] <<< Status: 200 OK +08:28:12 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:28:12 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "1e9be398831193b8e9a5da84f0296922" +} +08:28:12 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:28:12+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:28:12 [qqbot] [qqbot:default] Stream chunk sent, index: 0, isEnd: false, text: "我已经..." +08:28:12 [qqbot] [qqbot:default] Stream partial #1, increment: 3 chars, total: 3 chars +08:28:12 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:28:12 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:28:12 [qqbot-api] >>> Body: { +"markdown": { +"content": "为您设置了一个5分钟后提醒喝水的定时任务。让我再次确认一下这个提醒已经成功创建:" +}, +"msg_type": 2, +"msg_seq": 69934423, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 1, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:28:13 [qqbot-api] <<< Status: 200 OK +08:28:13 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:28:13 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "bd194a3c84af19b053c8c1c044c8fad9" +} +08:28:13 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:28:13+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:28:13 [qqbot] [qqbot:default] Stream chunk sent, index: 1, isEnd: false, text: "为您设置了一个5分钟后提醒喝水的定时任务。让我再次确认一下这个提醒已经成功创建:..." +08:28:13 [qqbot] [qqbot:default] Stream partial #2, increment: 40 chars, total: 43 chars +08:28:15 [ws] ⇄ res ✓ node.list 2ms id=02ca1f98…f997 +08:28:16 [qqbot] [qqbot:default] Sending keepalive #1 (elapsed: 6s, since chunk: 3s) +08:28:16 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:28:16 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:28:16 [qqbot-api] >>> Body: { +"markdown": { +"content": "" +}, +"msg_type": 2, +"msg_seq": 69934424, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 2, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:28:16 [qqbot-api] <<< Status: 200 OK +08:28:16 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:28:16 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "2eeba590381c912b5f1eb324ec471901" +} +08:28:16 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:28:16+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:28:20 [ws] ⇄ res ✓ node.list 3ms id=9d64851e…eec3 +08:28:21 [ws] ← open remoteAddr=127.0.0.1 conn=41fc0659…f78a +08:28:21 [ws] ← connect client=cli version=dev mode=cli clientId=cli platform=darwin auth=device-token +08:28:21 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=6 +08:28:21 [ws] → event health seq=22 clients=6 presenceVersion=6 healthVersion=9 +08:28:21 [ws] ⇄ res ✓ cron.list 0ms id=ee26e051…4691 +08:28:21 [ws] → close code=1005 reason= durationMs=45 handshake=connected lastFrameType=req lastFrameMethod=cron.list lastFrameId=ee26e051-977a-449c-b01d-38efaa2f4691 +08:28:21 [agent/embedded] embedded run tool end: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b tool=exec toolCallId=call_45275505bcb24671aac9d040 +08:28:25 [agent/embedded] embedded run tool start: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b tool=process toolCallId=call_360f71d0e0ed4cd0ab3001cf +08:28:25 [agent/embedded] embedded run tool end: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b tool=process toolCallId=call_360f71d0e0ed4cd0ab3001cf +08:28:25 [ws] ⇄ res ✓ node.list 1ms conn=f395f45d…3099 id=c1d093d8…8763 +08:28:26 [qqbot] [qqbot:default] Sending keepalive #2 (elapsed: 16s, since chunk: 14s) +08:28:26 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:28:26 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:28:26 [qqbot-api] >>> Body: { +"markdown": { +"content": "" +}, +"msg_type": 2, +"msg_seq": 69934425, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 3, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:28:28 [qqbot-api] <<< Status: 200 OK +08:28:28 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:28:28 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "b687de25f82d1b9147dbd36e87178b92" +} +08:28:28 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:28:28+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:28:29 [qqbot] [qqbot:default] Heartbeat sent +08:28:29 [qqbot] [qqbot:default] Received op=11 t=undefined +08:28:29 [qqbot] [qqbot:default] Heartbeat ACK +08:28:30 [ws] → event agent seq=23 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=14 text=奇怪 +08:28:30 [ws] → event chat seq=24 clients=5 dropIfSlow=true +08:28:30 [qqbot] [qqbot:default] New segment detected! lastSentLength=43, newTextLength=2, lastSentText="我已经为您设置了一个5分钟后提醒喝水的定...", newText="奇怪..." +08:28:30 [qqbot] [qqbot:default] handlePartialReply: fullText.length=2, lastSentLength=0, streamBuffer.length=47, isNewSegment=true +08:28:30 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=2 +08:28:30 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:28:30 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:28:30 [qqbot-api] >>> Body: { +"markdown": { +"content": "奇怪" +}, +"msg_type": 2, +"msg_seq": 69934426, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 4, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:28:30 [ws] → event agent seq=25 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=15 text=奇怪,我刚才明明 +08:28:30 [qqbot] [qqbot:default] handlePartialReply: fullText.length=8, lastSentLength=0, streamBuffer.length=53, isNewSegment=false +08:28:30 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=8 +08:28:30 [ws] → event agent seq=26 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=16 text=奇怪,我刚才明明设置了 +08:28:30 [qqbot] [qqbot:default] handlePartialReply: fullText.length=11, lastSentLength=0, streamBuffer.length=56, isNewSegment=false +08:28:30 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=11 +08:28:30 [ws] → event agent seq=27 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=17 text=奇怪,我刚才明明设置了提醒 +08:28:30 [qqbot] [qqbot:default] handlePartialReply: fullText.length=13, lastSentLength=0, streamBuffer.length=58, isNewSegment=false +08:28:30 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=13 +08:28:30 [ws] → event agent seq=28 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=18 text=奇怪,我刚才明明设置了提醒,但现在查看却没有 +08:28:30 [ws] → event chat seq=29 clients=5 dropIfSlow=true +08:28:30 [qqbot] [qqbot:default] handlePartialReply: fullText.length=22, lastSentLength=0, streamBuffer.length=67, isNewSegment=false +08:28:30 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=22 +08:28:30 [ws] → event agent seq=30 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=19 text=奇怪,我刚才明明设置了提醒,但现在查看却没有找到任何cron任务 +08:28:30 [qqbot] [qqbot:default] handlePartialReply: fullText.length=32, lastSentLength=0, streamBuffer.length=77, isNewSegment=false +08:28:30 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=32 +08:28:30 [ws] → event agent seq=31 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=20 text=奇怪,我刚才明明设置了提醒,但现在查看却没有找到任何cron任务。让我再 +08:28:30 [qqbot] [qqbot:default] handlePartialReply: fullText.length=36, lastSentLength=0, streamBuffer.length=81, isNewSegment=false +08:28:30 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=36 +08:28:30 [qqbot-api] <<< Status: 200 OK +08:28:30 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:28:30 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "db76f75156e1e7e8aec8ba21899869c9" +} +08:28:30 [ws] → event agent seq=32 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=21 text=奇怪,我刚才明明设置了提醒,但现在查看却没有找到任何cron任务。让我再为您设置一次5 +08:28:30 [ws] → event chat seq=33 clients=5 dropIfSlow=true +08:28:30 [qqbot] [qqbot:default] handlePartialReply: fullText.length=43, lastSentLength=0, streamBuffer.length=88, isNewSegment=false +08:28:30 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=43 +08:28:30 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:28:30+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:28:30 [qqbot] [qqbot:default] Stream chunk sent, index: 4, isEnd: false, text: "奇怪..." +08:28:30 [qqbot] [qqbot:default] Stream partial #5, increment: 2 chars, total: 2 chars +08:28:30 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:28:30 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:28:30 [qqbot-api] >>> Body: { +"markdown": { +"content": ",我刚才明明设置了提醒,但现在查看却没有找到任何cron任务。让我再为您设置一次5" +}, +"msg_type": 2, +"msg_seq": 69934427, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 5, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:28:30 [ws] → event agent seq=34 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=22 text=奇怪,我刚才明明设置了提醒,但现在查看却没有找到任何cron任务。让我再为您设置一次5分钟后提醒喝水的任务 +08:28:30 [qqbot] [qqbot:default] handlePartialReply: fullText.length=53, lastSentLength=2, streamBuffer.length=98, isNewSegment=false +08:28:30 [ws] → event agent seq=35 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=23 text=奇怪,我刚才明明设置了提醒,但现在查看却没有找到任何cron任务。让我再为您设置一次5分钟后提醒喝水的任务: +08:28:30 [qqbot] [qqbot:default] handlePartialReply: fullText.length=54, lastSentLength=2, streamBuffer.length=99, isNewSegment=false +08:28:30 [ws] ⇄ res ✓ node.list 3ms id=beba66e6…6e52 +08:28:31 [qqbot-api] <<< Status: 200 OK +08:28:31 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:28:31 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "70ff645913e01f94f4014c6679384b48" +} +08:28:31 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:28:31+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:28:31 [qqbot] [qqbot:default] Stream chunk sent, index: 5, isEnd: false, text: ",我刚才明明设置了提醒,但现在查看却没有找到任何cron任务。让我再为您设置一次5..." +08:28:31 [qqbot] [qqbot:default] Stream partial #6, increment: 41 chars, total: 43 chars +08:28:31 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:28:31 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:28:31 [qqbot-api] >>> Body: { +"markdown": { +"content": "分钟后提醒喝水的任务:" +}, +"msg_type": 2, +"msg_seq": 69934428, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 6, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:28:31 [qqbot-api] <<< Status: 200 OK +08:28:31 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:28:31 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "061e4f7d858be66d22dd886ce97225a0" +} +08:28:31 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:28:31+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:28:31 [qqbot] [qqbot:default] Stream chunk sent, index: 6, isEnd: false, text: "分钟后提醒喝水的任务:..." +08:28:31 [qqbot] [qqbot:default] Stream partial #7, increment: 11 chars, total: 54 chars +08:28:32 [ws] → event tick seq=36 clients=5 dropIfSlow=true +08:28:32 [ws] ⇄ res ✓ node.list 30ms conn=3f770fc7…c168 id=4645acb6…1a05 +08:28:32 [ws] ⇄ res ✓ node.list 7ms conn=d582275d…8cf7 id=f4425358…807e +08:28:32 [ws] ⇄ res ✓ node.list 9ms conn=30e84c3b…37f9 id=e90ce807…5806 +08:28:32 [ws] ⇄ res ✓ node.list 14ms conn=d84f2780…f80d id=9f6c21c9…5ec8 +08:28:32 [agent/embedded] embedded run tool start: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b tool=exec toolCallId=call_912594aaec8b4dcfaae751f7 +08:28:34 [qqbot] [qqbot:default] Sending keepalive #1 (elapsed: 24s, since chunk: 3s) +08:28:34 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:28:34 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:28:34 [qqbot-api] >>> Body: { +"markdown": { +"content": "" +}, +"msg_type": 2, +"msg_seq": 69934429, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 7, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:28:35 [qqbot-api] <<< Status: 200 OK +08:28:35 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:28:35 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "a28217b289890ad50f06343398486b54" +} +08:28:35 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:28:35+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:28:35 [ws] ⇄ res ✓ node.list 3ms conn=f395f45d…3099 id=0ff7cfa6…e3e9 +08:28:40 tools: exec failed stack: +Error: error: unknown option '--reply-to' + +Command exited with code 1 +at file:///Users/lishoushuai/.nvm/versions/node/v22.14.0/lib/node_modules/openclaw/dist/agents/bash-tools.exec.js:1146:32 +08:28:40 [tools] exec failed: error: unknown option '--reply-to' + +Command exited with code 1 +08:28:40 [agent/embedded] embedded run tool end: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b tool=exec toolCallId=call_912594aaec8b4dcfaae751f7 +08:28:40 [ws] ⇄ res ✓ node.list 1ms id=10e301db…d453 +08:28:43 [ws] → event agent seq=37 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=27 text=我 +08:28:43 [ws] → event chat seq=38 clients=5 dropIfSlow=true +08:28:43 [qqbot] [qqbot:default] New segment detected! lastSentLength=54, newTextLength=1, lastSentText="奇怪,我刚才明明设置了提醒,但现在查看却...", newText="我..." +08:28:43 [qqbot] [qqbot:default] handlePartialReply: fullText.length=1, lastSentLength=0, streamBuffer.length=102, isNewSegment=true +08:28:43 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=1 +08:28:43 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:28:43 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:28:43 [qqbot-api] >>> Body: { +"markdown": { +"content": "我" +}, +"msg_type": 2, +"msg_seq": 69934430, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 8, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:28:43 [ws] → event agent seq=39 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=28 text=我明白了问题 +08:28:43 [qqbot] [qqbot:default] handlePartialReply: fullText.length=6, lastSentLength=0, streamBuffer.length=107, isNewSegment=false +08:28:43 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=6 +08:28:43 [ws] → event agent seq=40 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=29 text=我明白了问题所在 +08:28:43 [qqbot] [qqbot:default] handlePartialReply: fullText.length=8, lastSentLength=0, streamBuffer.length=109, isNewSegment=false +08:28:43 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=8 +08:28:43 [ws] → event agent seq=41 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=30 text=我明白了问题所在, +08:28:43 [qqbot] [qqbot:default] handlePartialReply: fullText.length=9, lastSentLength=0, streamBuffer.length=110, isNewSegment=false +08:28:43 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=9 +08:28:43 [ws] → event agent seq=42 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=31 text=我明白了问题所在,`--reply-to +08:28:43 [ws] → event chat seq=43 clients=5 dropIfSlow=true +08:28:43 [qqbot] [qqbot:default] handlePartialReply: fullText.length=20, lastSentLength=0, streamBuffer.length=121, isNewSegment=false +08:28:43 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=20 +08:28:43 [ws] → event agent seq=44 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=32 text=我明白了问题所在,`--reply-to` +08:28:43 [qqbot] [qqbot:default] handlePartialReply: fullText.length=21, lastSentLength=0, streamBuffer.length=122, isNewSegment=false +08:28:43 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=21 +08:28:43 [ws] → event agent seq=45 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=33 text=我明白了问题所在,`--reply-to` 参数似乎在当前 +08:28:43 [qqbot] [qqbot:default] handlePartialReply: fullText.length=29, lastSentLength=0, streamBuffer.length=130, isNewSegment=false +08:28:43 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=29 +08:28:43 [ws] → event agent seq=46 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=34 text=我明白了问题所在,`--reply-to` 参数似乎在当前版本的 cron +08:28:43 [ws] → event chat seq=47 clients=5 dropIfSlow=true +08:28:43 [qqbot] [qqbot:default] handlePartialReply: fullText.length=37, lastSentLength=0, streamBuffer.length=138, isNewSegment=false +08:28:43 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=37 +08:28:43 [ws] → event agent seq=48 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=35 text=我明白了问题所在,`--reply-to` 参数似乎在当前版本的 cron 命令中 +08:28:43 [qqbot] [qqbot:default] handlePartialReply: fullText.length=41, lastSentLength=0, streamBuffer.length=142, isNewSegment=false +08:28:43 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=41 +08:28:44 [ws] → event agent seq=49 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=36 text=我明白了问题所在,`--reply-to` 参数似乎在当前版本的 cron 命令中不存在。让我根据 +08:28:44 [qqbot] [qqbot:default] handlePartialReply: fullText.length=49, lastSentLength=0, streamBuffer.length=150, isNewSegment=false +08:28:44 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=49 +08:28:44 [ws] → event agent seq=50 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=37 text=我明白了问题所在,`--reply-to` 参数似乎在当前版本的 cron 命令中不存在。让我根据系统提供的信息重新 +08:28:44 [ws] → event chat seq=51 clients=5 dropIfSlow=true +08:28:44 [qqbot] [qqbot:default] handlePartialReply: fullText.length=58, lastSentLength=0, streamBuffer.length=159, isNewSegment=false +08:28:44 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=58 +08:28:44 [ws] → event agent seq=52 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=38 text=我明白了问题所在,`--reply-to` 参数似乎在当前版本的 cron 命令中不存在。让我根据系统提供的信息重新设置提醒,去掉 +08:28:44 [ws] → event chat seq=53 clients=5 dropIfSlow=true +08:28:44 [qqbot] [qqbot:default] handlePartialReply: fullText.length=65, lastSentLength=0, streamBuffer.length=166, isNewSegment=false +08:28:44 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=65 +08:28:44 [ws] → event agent seq=54 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=39 text=我明白了问题所在,`--reply-to` 参数似乎在当前版本的 cron 命令中不存在。让我根据系统提供的信息重新设置提醒,去掉不支持的参数 +08:28:44 [qqbot] [qqbot:default] handlePartialReply: fullText.length=71, lastSentLength=0, streamBuffer.length=172, isNewSegment=false +08:28:44 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=71 +08:28:44 [ws] → event agent seq=55 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=40 text=我明白了问题所在,`--reply-to` 参数似乎在当前版本的 cron 命令中不存在。让我根据系统提供的信息重新设置提醒,去掉不支持的参数: +08:28:44 [qqbot] [qqbot:default] handlePartialReply: fullText.length=72, lastSentLength=0, streamBuffer.length=173, isNewSegment=false +08:28:44 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=72 +08:28:45 [qqbot-api] <<< Status: 200 OK +08:28:45 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:28:45 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "dce05ada4170c53c5ea7f6207c77b5ab" +} +08:28:45 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:28:45+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:28:45 [qqbot] [qqbot:default] Stream chunk sent, index: 8, isEnd: false, text: "我..." +08:28:45 [qqbot] [qqbot:default] Stream partial #9, increment: 1 chars, total: 1 chars +08:28:45 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:28:45 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:28:45 [qqbot-api] >>> Body: { +"markdown": { +"content": "明白了问题所在,`--reply-to` 参数似乎在当前版本的 cron 命令中不存在。让我根据系统提供的信息重新设置提醒,去掉不支持的参数:" +}, +"msg_type": 2, +"msg_seq": 69934431, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 9, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:28:45 [qqbot-api] <<< Status: 200 OK +08:28:45 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:28:45 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "52bbde0882bfc077bf1b3784e059d358" +} +08:28:45 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:28:45+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:28:45 [qqbot] [qqbot:default] Stream chunk sent, index: 9, isEnd: false, text: "明白了问题所在,`--reply-to` 参数似乎在当前版本的 cron 命令中不存在。让我根据系统..." +08:28:45 [qqbot] [qqbot:default] Stream partial #10, increment: 71 chars, total: 72 chars +08:28:45 [agent/embedded] embedded run tool start: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b tool=exec toolCallId=call_5eb242f4bd724c8c8903f449 +08:28:45 [ws] ⇄ res ✓ node.list 1ms id=92a48702…c670 +08:28:48 [qqbot] [qqbot:default] Sending keepalive #1 (elapsed: 38s, since chunk: 3s) +08:28:48 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:28:48 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:28:48 [qqbot-api] >>> Body: { +"markdown": { +"content": "" +}, +"msg_type": 2, +"msg_seq": 69934432, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 10, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:28:50 [qqbot-api] <<< Status: 200 OK +08:28:50 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:28:50 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "ecc67124362fb054de3c318a9ec372c4" +} +08:28:50 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:28:50+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:28:50 [ws] ⇄ res ✓ node.list 2ms id=35f24303…102e +08:28:54 [ws] ← open remoteAddr=127.0.0.1 conn=7c10c747…e275 +08:28:54 [ws] ← connect client=cli version=dev mode=cli clientId=cli platform=darwin auth=device-token +08:28:54 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=6 +08:28:54 [ws] → event health seq=56 clients=6 presenceVersion=6 healthVersion=10 +08:28:54 [ws] → event cron seq=57 clients=6 dropIfSlow=true +08:28:54 [ws] ⇄ res ✓ cron.add 7ms id=89227308…8748 +08:28:54 [ws] → close code=1005 reason= durationMs=65 handshake=connected lastFrameType=req lastFrameMethod=cron.add lastFrameId=89227308-d24e-46e1-9384-e87ed93b8748 +08:28:54 [ws] ← open remoteAddr=127.0.0.1 conn=4d66603c…ea45 +08:28:54 [ws] ← connect client=cli version=dev mode=cli clientId=cli platform=darwin auth=device-token +08:28:54 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=6 +08:28:54 [ws] → event health seq=58 clients=6 presenceVersion=6 healthVersion=11 +08:28:54 [ws] ⇄ res ✓ cron.status 1ms id=3c52f4c3…aaef +08:28:54 [ws] → close code=1005 reason= durationMs=15 handshake=connected lastFrameType=req lastFrameMethod=cron.status lastFrameId=3c52f4c3-a561-4ac8-9bd2-01c0875aaaef +08:28:54 [agent/embedded] embedded run tool end: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b tool=exec toolCallId=call_5eb242f4bd724c8c8903f449 +08:28:55 [ws] ⇄ res ✓ node.list 0ms conn=f395f45d…3099 id=cf12e732…a5c2 +08:29:00 [qqbot] [qqbot:default] Sending keepalive #2 (elapsed: 49s, since chunk: 14s) +08:29:00 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:29:00 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:29:00 [qqbot-api] >>> Body: { +"markdown": { +"content": "" +}, +"msg_type": 2, +"msg_seq": 69934433, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 11, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:29:00 [ws] ⇄ res ✓ node.list 2ms id=4e006266…9e62 +08:29:01 [qqbot-api] <<< Status: 200 OK +08:29:01 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:29:01 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "4290ff8b0e8b880ee5610b0f7d80852f" +} +08:29:01 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:29:01+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:29:02 [ws] → event health seq=59 clients=5 presenceVersion=6 healthVersion=12 +08:29:02 [ws] → event tick seq=60 clients=5 dropIfSlow=true +08:29:04 [ws] → event agent seq=61 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=44 text=太 +08:29:04 [ws] → event chat seq=62 clients=5 dropIfSlow=true +08:29:04 [qqbot] [qqbot:default] New segment detected! lastSentLength=72, newTextLength=1, lastSentText="我明白了问题所在,`--reply-to...", newText="太..." +08:29:04 [qqbot] [qqbot:default] handlePartialReply: fullText.length=1, lastSentLength=0, streamBuffer.length=176, isNewSegment=true +08:29:04 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=1 +08:29:04 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:29:04 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:29:04 [qqbot-api] >>> Body: { +"markdown": { +"content": "太" +}, +"msg_type": 2, +"msg_seq": 69934434, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 12, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:29:04 [ws] → event agent seq=63 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=45 text=太好了!这次 +08:29:04 [qqbot] [qqbot:default] handlePartialReply: fullText.length=6, lastSentLength=0, streamBuffer.length=181, isNewSegment=false +08:29:04 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=6 +08:29:04 [ws] → event agent seq=64 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=46 text=太好了!这次提醒设置 +08:29:04 [qqbot] [qqbot:default] handlePartialReply: fullText.length=10, lastSentLength=0, streamBuffer.length=185, isNewSegment=false +08:29:04 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=10 +08:29:04 [ws] → event agent seq=65 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=47 text=太好了!这次提醒设置成功了。 +08:29:04 [qqbot] [qqbot:default] handlePartialReply: fullText.length=14, lastSentLength=0, streamBuffer.length=189, isNewSegment=false +08:29:04 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=14 +08:29:04 [ws] → event agent seq=66 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=48 text=太好了!这次提醒设置成功了。让我确认一下当前 +08:29:04 [ws] → event chat seq=67 clients=5 dropIfSlow=true +08:29:04 [qqbot] [qqbot:default] handlePartialReply: fullText.length=22, lastSentLength=0, streamBuffer.length=197, isNewSegment=false +08:29:04 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=22 +08:29:04 [ws] → event agent seq=68 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=49 text=太好了!这次提醒设置成功了。让我确认一下当前的提醒列表: +08:29:04 [qqbot] [qqbot:default] handlePartialReply: fullText.length=28, lastSentLength=0, streamBuffer.length=203, isNewSegment=false +08:29:04 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=28 +08:29:04 [agent/embedded] embedded run tool start: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b tool=exec toolCallId=call_4ac86cf98611444ca4feb0ad +08:29:04 [qqbot-api] <<< Status: 200 OK +08:29:04 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:29:04 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "6a598176480a4164edbca5d53ce5ccc1" +} +08:29:04 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:29:04+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:29:04 [qqbot] [qqbot:default] Stream chunk sent, index: 12, isEnd: false, text: "太..." +08:29:04 [qqbot] [qqbot:default] Stream partial #13, increment: 1 chars, total: 1 chars +08:29:04 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:29:04 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:29:04 [qqbot-api] >>> Body: { +"markdown": { +"content": "好了!这次提醒设置成功了。让我确认一下当前的提醒列表:" +}, +"msg_type": 2, +"msg_seq": 69934435, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 13, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:29:05 [qqbot-api] <<< Status: 200 OK +08:29:05 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:29:05 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "ee274468916b0f43bac839509aa0fa31" +} +08:29:05 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:29:05+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:29:05 [qqbot] [qqbot:default] Stream chunk sent, index: 13, isEnd: false, text: "好了!这次提醒设置成功了。让我确认一下当前的提醒列表:..." +08:29:05 [qqbot] [qqbot:default] Stream partial #14, increment: 27 chars, total: 28 chars +08:29:05 [ws] ⇄ res ✓ node.list 1ms id=6e471ada…faeb +08:29:08 [qqbot] [qqbot:default] Sending keepalive #1 (elapsed: 58s, since chunk: 3s) +08:29:08 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:29:08 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:29:08 [qqbot-api] >>> Body: { +"markdown": { +"content": "" +}, +"msg_type": 2, +"msg_seq": 69934436, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 14, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:29:09 [qqbot-api] <<< Status: 200 OK +08:29:09 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:29:09 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "c87fa23d25bf5155e7b8db5a7576e846" +} +08:29:09 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:29:09+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:29:10 [ws] ← open remoteAddr=127.0.0.1 conn=6f9da40f…d57c +08:29:10 [ws] ← connect client=cli version=dev mode=cli clientId=cli platform=darwin auth=device-token +08:29:10 [ws] → hello-ok methods=80 events=18 presence=2 stateVersion=6 +08:29:10 [ws] → event health seq=69 clients=6 presenceVersion=6 healthVersion=13 +08:29:10 [ws] ⇄ res ✓ cron.list 1ms id=44a1542c…3d8a +08:29:10 [ws] → close code=1005 reason= durationMs=33 handshake=connected lastFrameType=req lastFrameMethod=cron.list lastFrameId=44a1542c-ecca-4eb2-8af2-bf6ea2823d8a +08:29:10 [agent/embedded] embedded run tool end: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b tool=exec toolCallId=call_4ac86cf98611444ca4feb0ad +08:29:10 [qqbot] [qqbot:default] Heartbeat sent +08:29:10 [ws] ⇄ res ✓ node.list 3ms conn=f395f45d…3099 id=ce7b0659…d104 +08:29:11 [qqbot] [qqbot:default] Received op=11 t=undefined +08:29:11 [qqbot] [qqbot:default] Heartbeat ACK +08:29:15 [ws] → event agent seq=70 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=54 text=✅ +08:29:15 [ws] → event chat seq=71 clients=5 dropIfSlow=true +08:29:15 [qqbot] [qqbot:default] New segment detected! lastSentLength=28, newTextLength=1, lastSentText="太好了!这次提醒设置成功了。让我确认一下...", newText="✅..." +08:29:15 [qqbot] [qqbot:default] handlePartialReply: fullText.length=1, lastSentLength=0, streamBuffer.length=206, isNewSegment=true +08:29:15 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=1 +08:29:15 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:29:15 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:29:15 [qqbot-api] >>> Body: { +"markdown": { +"content": "✅" +}, +"msg_type": 2, +"msg_seq": 69934437, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 15, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:29:15 [ws] → event agent seq=72 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=55 text=✅ 提 +08:29:15 [qqbot] [qqbot:default] handlePartialReply: fullText.length=3, lastSentLength=0, streamBuffer.length=208, isNewSegment=false +08:29:15 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=3 +08:29:15 [ws] → event agent seq=73 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=56 text=✅ 提醒已设置成功 +08:29:15 [qqbot] [qqbot:default] handlePartialReply: fullText.length=9, lastSentLength=0, streamBuffer.length=214, isNewSegment=false +08:29:15 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=9 +08:29:15 [ws] → event agent seq=74 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=57 text=✅ 提醒已设置成功! 📝 +08:29:15 [qqbot] [qqbot:default] handlePartialReply: fullText.length=14, lastSentLength=0, streamBuffer.length=219, isNewSegment=false +08:29:15 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=14 +08:29:15 [ws] ⇄ res ✓ node.list 1ms id=ebe1e48d…3352 +08:29:16 [ws] → event agent seq=75 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=58 text=✅ 提醒已设置成功! 📝 内容: +08:29:16 [ws] → event chat seq=76 clients=5 dropIfSlow=true +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=18, lastSentLength=0, streamBuffer.length=223, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=18 +08:29:16 [ws] → event agent seq=77 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=59 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间 +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=25, lastSentLength=0, streamBuffer.length=230, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=25 +08:29:16 [ws] → event agent seq=78 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=60 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5 +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=27, lastSentLength=0, streamBuffer.length=232, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=27 +08:29:16 [ws] → event agent seq=79 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=61 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 +08:29:16 [ws] → event chat seq=80 clients=5 dropIfSlow=true +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=35, lastSentLength=0, streamBuffer.length=240, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=35 +08:29:16 [ws] → event agent seq=81 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=62 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:3 +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=40, lastSentLength=0, streamBuffer.length=245, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=40 +08:29:16 [ws] → event agent seq=82 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=63 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会 +08:29:16 [ws] → event chat seq=83 clients=5 dropIfSlow=true +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=48, lastSentLength=0, streamBuffer.length=253, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=48 +08:29:16 [ws] → event agent seq=84 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=64 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=54, lastSentLength=0, streamBuffer.length=259, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=54 +08:29:16 [ws] → event agent seq=85 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=65 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经 +08:29:16 [ws] → event chat seq=86 clients=5 dropIfSlow=true +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=64, lastSentLength=0, streamBuffer.length=269, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=64 +08:29:16 [ws] → event agent seq=87 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=66 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建 +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=66, lastSentLength=0, streamBuffer.length=271, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=66 +08:29:16 [ws] → event agent seq=88 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=67 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在5分钟后 +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=73, lastSentLength=0, streamBuffer.length=278, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=73 +08:29:16 [ws] → event agent seq=89 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=68 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在5分钟后执行 +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=75, lastSentLength=0, streamBuffer.length=280, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=75 +08:29:16 [ws] → event agent seq=90 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=69 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在5分钟后执行,届时您会 +08:29:16 [ws] → event chat seq=91 clients=5 dropIfSlow=true +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=80, lastSentLength=0, streamBuffer.length=285, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=80 +08:29:16 [ws] → event agent seq=92 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=70 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在5分钟后执行,届时您会收到 +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=82, lastSentLength=0, streamBuffer.length=287, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=82 +08:29:16 [ws] → event agent seq=93 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=71 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在5分钟后执行,届时您会收到一条"💧 +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=87, lastSentLength=0, streamBuffer.length=292, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=87 +08:29:16 [ws] → event agent seq=94 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=72 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在5分钟后执行,届时您会收到一条"💧 该喝水 +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=91, lastSentLength=0, streamBuffer.length=296, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=91 +08:29:16 [ws] → event agent seq=95 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=73 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在5分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持 +08:29:16 [qqbot] [qqbot:default] handlePartialReply: fullText.length=97, lastSentLength=0, streamBuffer.length=302, isNewSegment=false +08:29:16 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=97 +08:29:16 [qqbot-api] <<< Status: 200 OK +08:29:16 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:29:17 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "42b9030307d7e3b838a856700fbe2422" +} +08:29:16 [ws] → event agent seq=96 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=74 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建并将在5分钟后执行,届时您会收到一条"💧 该喝水啦!记得保持充足的水分摄入, +08:29:17 [ws] → event chat seq=97 clients=5 dropIfSlow=true +08:29:17 [qqbot] [qqbot:default] handlePartialReply: fullText.length=105, lastSentLength=0, streamBuffer.length=310, isNewSegment=false +08:29:17 [qqbot] [qqbot:default] handlePartialReply: sending stream chunk, length=105 +08:29:17 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:29:17+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:29:17 [qqbot] [qqbot:default] Stream chunk sent, index: 15, isEnd: false, text: "✅..." +08:29:17 [qqbot] [qqbot:default] Stream partial #16, increment: 1 chars, total: 1 chars +08:29:17 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:29:17 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:29:17 [qqbot-api] >>> Body: { +"markdown": { +"content": " 提醒已设置成功!\n\n📝 内容:喝水\n⏰ 时间:5分钟后 (大约在 16:33)\n到时候我会准时提醒您~\n\n您的提醒任务已经创建并将在5分钟后执行,届时您会收到一条\"💧 该喝水啦!记得保持充足的水分摄入," +}, +"msg_type": 2, +"msg_seq": 69934438, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 16, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:29:17 [ws] → event agent seq=98 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=75 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建并将帮助 +08:29:17 [qqbot] [qqbot:default] handlePartialReply: fullText.length=114, lastSentLength=1, streamBuffer.length=319, isNewSegment=false +08:29:17 [ws] → event agent seq=99 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=76 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建并将帮助哦~" +08:29:17 [qqbot] [qqbot:default] handlePartialReply: fullText.length=117, lastSentLength=1, streamBuffer.length=322, isNewSegment=false +08:29:17 [ws] → event agent seq=100 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=77 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建并将有帮助哦~"的消息。提醒将在 +08:29:17 [ws] → event chat seq=101 clients=5 dropIfSlow=true +08:29:17 [qqbot] [qqbot:default] handlePartialReply: fullText.length=125, lastSentLength=1, streamBuffer.length=330, isNewSegment=false +08:29:17 [ws] → event agent seq=102 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=78 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建并将有帮助哦~"的消息。提醒将在执行后自动删除 +08:29:17 [ws] → event chat seq=103 clients=5 dropIfSlow=true +08:29:17 [qqbot] [qqbot:default] handlePartialReply: fullText.length=132, lastSentLength=1, streamBuffer.length=337, isNewSegment=false +08:29:17 [ws] → event agent seq=104 clients=5 run=49d64fc4…e41b agent=main session=main stream=assistant aseq=79 text=✅ 提醒已设置成功! 📝 内容:喝水 ⏰ 时间:5分钟后 (大约在 16:33) 到时候我会准时提醒您~ 您的提醒任务已经创建并将有帮助哦~"的消息。提醒将在执行后自动删除。 +08:29:17 [qqbot] [qqbot:default] handlePartialReply: fullText.length=133, lastSentLength=1, streamBuffer.length=338, isNewSegment=false +08:29:17 [agent/embedded] embedded run agent end: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b +08:29:17 [ws] → event agent seq=105 clients=5 run=49d64fc4…e41b agent=main session=main stream=lifecycle aseq=80 phase=end +08:29:17 [ws] → event chat seq=106 clients=5 +08:29:17 [agent/embedded] embedded run prompt end: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b sessionId=ba108bac-c99c-498f-b33f-06245ade1363 durationMs=74069 +08:29:17 [diagnostic] session state: sessionId=ba108bac-c99c-498f-b33f-06245ade1363 sessionKey=unknown prev=processing new=idle reason="run_completed" queueDepth=0 +08:29:17 [diagnostic] run cleared: sessionId=ba108bac-c99c-498f-b33f-06245ade1363 totalActive=0 +08:29:17 [ws] ⇄ res ✓ chat.history 17ms conn=3f770fc7…c168 id=15fe49d7…4c9c +08:29:17 [ws] ⇄ res ✓ chat.history 17ms conn=d582275d…8cf7 id=83955d28…1198 +08:29:17 [ws] ⇄ res ✓ chat.history 6ms conn=d84f2780…f80d id=8c36b63a…8b6e +08:29:17 [ws] ⇄ res ✓ chat.history 10ms conn=30e84c3b…37f9 id=b09e1eb5…e1fa +08:29:17 [ws] ⇄ res ✓ chat.history 6ms conn=f395f45d…3099 id=c08d10c0…6fbd +08:29:17 [qqbot-api] <<< Status: 200 OK +08:29:17 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:29:17 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "230c60a2e8e313d4315ba94e35baba51" +} +08:29:17 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:29:17+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:29:17 [qqbot] [qqbot:default] Stream chunk sent, index: 16, isEnd: false, text: " 提醒已设置成功! + +📝 内容:喝水 +⏰ 时间:5分钟后 (大约在 16:33) +到时候我会准时提..." +08:29:17 [qqbot] [qqbot:default] Stream partial #17, increment: 104 chars, total: 105 chars +08:29:17 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:29:17 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:29:17 [qqbot-api] >>> Body: { +"markdown": { +"content": "对身体健康很有帮助哦~\"的消息。提醒将在执行后自动删除。" +}, +"msg_type": 2, +"msg_seq": 69934439, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 1, +"index": 17, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:29:17 [agent/embedded] embedded run done: runId=49d64fc4-e0f9-477f-99d8-f3efa3a0e41b sessionId=ba108bac-c99c-498f-b33f-06245ade1363 durationMs=74520 aborted=false +08:29:17 [diagnostic] lane task done: lane=main durationMs=74539 active=0 queued=0 +08:29:17 [diagnostic] lane task done: lane=session:agent:main:main durationMs=74543 active=0 queued=0 +08:29:17 [qqbot] [qqbot:default] deliver called, kind: final, payload keys: text, mediaUrls, mediaUrl, isError, replyToId, replyToTag, replyToCurrent, audioAsVoice +08:29:17 [qqbot] [qqbot:default] deliver called, kind: final, payload keys: text, mediaUrls, mediaUrl, isError, replyToId, replyToTag, replyToCurrent, audioAsVoice +08:29:17 [qqbot] [qqbot:default] deliver called, kind: final, payload keys: text, mediaUrls, mediaUrl, isError, replyToId, replyToTag, replyToCurrent, audioAsVoice +08:29:17 [qqbot] [qqbot:default] deliver called, kind: final, payload keys: text, mediaUrls, mediaUrl, isError, replyToId, replyToTag, replyToCurrent, audioAsVoice +08:29:17 [qqbot] [qqbot:default] deliver called, kind: final, payload keys: text, mediaUrls, mediaUrl, isError, replyToId, replyToTag, replyToCurrent, audioAsVoice +08:29:18 [ws] → event heartbeat seq=107 clients=5 dropIfSlow=true +08:29:19 [qqbot-api] <<< Status: 200 OK +08:29:19 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:29:18 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "e5678ab04a2ed8977c7a736f721aa76f" +} +08:29:19 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:29:18+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:29:19 [qqbot] [qqbot:default] Stream chunk sent, index: 17, isEnd: false, text: "对身体健康很有帮助哦~"的消息。提醒将在执行后自动删除。..." +08:29:19 [qqbot] [qqbot:default] Stream partial #18, increment: 28 chars, total: 133 chars +08:29:19 [qqbot-api] >>> POST https://api.sgroup.qq.com/v2/users/207A5B8339D01F6582911C014668B77B/messages +08:29:19 [qqbot-api] >>> Headers: { +"Authorization": "QQBot Sg8XiGmMDO9yWtIzk-9uLqsq9KuFz7-lYWMzbeFOj-Vv9l5kdh0psYZdaD1wMIvdWKgHclaXoK2V", +"Content-Type": "application/json" +} +08:29:19 [qqbot-api] >>> Body: { +"markdown": { +"content": "" +}, +"msg_type": 2, +"msg_seq": 69934440, +"msg_id": "ROBOT1.0_kbcHtHqrSD7wOngdk8bLt2DJiE0iiDDqYy5ybyxRKcrO7JcBTYvD1s9zXt5auvjbGb1zS6Iz4jKZcvz9xFO5igjlj6H3wBCAbx3PilKrAso!", +"stream": { +"state": 10, +"index": 18, +"id": "ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!" +} +} +08:29:20 [qqbot-api] <<< Status: 200 OK +08:29:20 [qqbot-api] <<< Headers: { +"access-control-allow-credentials": "true", +"access-control-allow-headers": "DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,authorization", +"access-control-allow-methods": "GET, POST, OPTIONS", +"connection": "keep-alive", +"content-encoding": "gzip", +"content-type": "application/json", +"date": "Sun, 01 Feb 2026 08:29:20 GMT", +"server": "TAPISIX/2.2.2", +"transfer-encoding": "chunked", +"vary": "Accept-Encoding", +"x-content-type-options": "nosniff", +"x-tps-trace-id": "d9cde505d1f2e65ccb4c9ef5b7a42435" +} +08:29:20 [qqbot-api] <<< Body: {"id":"ROBOT1.0_ZrebFMxtQ8iOJgOoGwIpn4b-s6SD6o.h9yF5myXaVq0sIoguSh8r12oxUi8LehQpIFWWYOTXJFg4qYG93f7Aug!!","timestamp":"2026-02-01T16:29:20+08:00","ext_info":{"ref_idx":"REFIDX_COG2AhCcnfzLBhjd7Oa+Dg=="}} +08:29:20 [qqbot] [qqbot:default] Stream completed, final increment: 0 chars, total streamBuffer: 338 chars, chunks: 19 +08:29:20 [ws] ⇄ res ✓ node.list 2ms id=785fc4ca…d50e +08:29:25 [ws] ⇄ res ✓ node.list 0ms id=04fce377…7c39 +08:29:30 [ws] ⇄ res ✓ node.list 1ms id=508c034d…ef00 +08:29:32 [ws] → event tick seq=108 clients=5 dropIfSlow=true +08:29:32 [ws] ⇄ res ✓ node.list 37ms conn=30e84c3b…37f9 id=e58ccf94…226a +08:29:32 [ws] ⇄ res ✓ node.list 107ms conn=d582275d…8cf7 id=3877bd6b…89c6 +08:29:32 [ws] ⇄ res ✓ node.list 112ms conn=d84f2780…f80d id=1a2c8310…4582 +08:29:32 [ws] ⇄ res ✓ node.list 122ms conn=3f770fc7…c168 id=418dd535…513b +08:29:35 [ws] ⇄ res ✓ node.list 1ms conn=f395f45d…3099 id=a69018c7…2ad6 +^C08:29:37 [gateway] signal SIGINT received +08:29:37 [gateway] received SIGINT; shutting down +08:29:37 [qqbot] [qqbot-api] Background token refresh stopped +08:29:37 [gmail-watcher] gmail watcher stopped +08:29:37 [ws] → event shutdown seq=109 clients=5 +08:29:37 [gateway] signal SIGINT received +08:29:37 [gateway] received SIGINT during shutdown; ignoring +08:29:37 [qqbot] [qqbot:default] Message processor stopped +08:29:37 [ws] webchat disconnected code=1012 reason=service restart conn=f395f45d-ef22-42bc-b309-01d0a8b13099 +08:29:37 [ws] → event presence seq=110 clients=0 dropIfSlow=true presenceVersion=7 healthVersion=13 +08:29:37 [ws] → close code=1012 reason=service restart durationMs=148474 handshake=connected lastFrameType=req lastFrameMethod=node.list lastFrameId=a69018c7-cf65-4275-b430-bf3ea5fd2ad6 +08:29:37 [ws] webchat disconnected code=1012 reason=service restart conn=30e84c3b-ec6d-4d80-aaf8-b42c47ad37f9 +08:29:37 [ws] → event presence seq=111 clients=0 dropIfSlow=true presenceVersion=8 healthVersion=13 +08:29:37 [ws] → close code=1012 reason=service restart durationMs=149077 handshake=connected lastFrameType=req lastFrameMethod=node.list lastFrameId=e58ccf94-9248-46f3-932b-e8e89624226a conn=30e84c3b…37f9 +08:29:37 [ws] webchat disconnected code=1012 reason=service restart conn=d84f2780-43ad-4cf7-800a-8dc2a97bf80d +08:29:37 [ws] → event presence seq=112 clients=0 dropIfSlow=true presenceVersion=9 healthVersion=13 +08:29:37 [ws] → close code=1012 reason=service restart durationMs=150032 handshake=connected lastFrameType=req lastFrameMethod=node.list lastFrameId=1a2c8310-e1d2-49a4-b9c7-d8e134854582 conn=d84f2780…f80d +08:29:37 [ws] webchat disconnected code=1012 reason=service restart conn=d582275d-ed0e-408f-a43e-1e0777478cf7 +08:29:37 [ws] → event presence seq=113 clients=0 dropIfSlow=true presenceVersion=10 healthVersion=13 +08:29:37 [ws] → close code=1012 reason=service restart durationMs=150052 handshake=connected lastFrameType=req lastFrameMethod=node.list lastFrameId=3877bd6b-1cb1-4654-a699-71875f9189c6 conn=d582275d…8cf7 +08:29:37 [ws] webchat disconnected code=1012 reason=service restart conn=3f770fc7-7722-4458-8fdb-86284e5cc168 +08:29:37 [ws] → event presence seq=114 clients=0 dropIfSlow=true presenceVersion=11 healthVersion=13 +08:29:37 [ws] → close code=1012 reason=service restart durationMs=150093 handshake=connected lastFrameType=req lastFrameMethod=node.list lastFrameId=418dd535-ddc7-4f9a-adc8-b39cfa25513b conn=3f770fc7…c168 diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 32017b4..1184778 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,6 +1,14 @@ { "id": "qqbot", + "name": "QQ Bot Channel", + "description": "QQ Bot channel plugin with streaming message support, cron jobs, and proactive messaging", "channels": ["qqbot"], + "skills": ["qqbot-cron"], + "capabilities": { + "proactiveMessaging": true, + "cronJobs": true, + "streamingMessages": true + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/scripts/proactive-api-server.ts b/scripts/proactive-api-server.ts new file mode 100644 index 0000000..db56b2a --- /dev/null +++ b/scripts/proactive-api-server.ts @@ -0,0 +1,346 @@ +/** + * QQBot 主动消息 HTTP API 服务 + * + * 提供 RESTful API 用于: + * 1. 发送主动消息 + * 2. 查询已知用户 + * 3. 广播消息 + * + * 启动方式: + * npx ts-node scripts/proactive-api-server.ts --port 3721 + * + * API 端点: + * POST /send - 发送主动消息 + * GET /users - 列出已知用户 + * GET /users/stats - 获取用户统计 + * POST /broadcast - 广播消息 + */ + +import * as http from "node:http"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as url from "node:url"; +import { + sendProactiveMessageDirect, + listKnownUsers, + getKnownUsersStats, + getKnownUser, + broadcastMessage, +} from "../src/proactive.js"; +import type { ResolvedQQBotAccount } from "../src/types.js"; + +// 默认端口 +const DEFAULT_PORT = 3721; + +// 从配置文件加载账户信息 +function loadAccount(accountId = "default"): ResolvedQQBotAccount | null { + const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json"); + + try { + // 优先从环境变量获取 + const envAppId = process.env.QQBOT_APP_ID; + const envClientSecret = process.env.QQBOT_CLIENT_SECRET; + + if (!fs.existsSync(configPath)) { + if (envAppId && envClientSecret) { + return { + accountId, + appId: envAppId, + clientSecret: envClientSecret, + enabled: true, + secretSource: "env", + }; + } + return null; + } + + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + const qqbot = config.channels?.qqbot; + + if (!qqbot) { + if (envAppId && envClientSecret) { + return { + accountId, + appId: envAppId, + clientSecret: envClientSecret, + enabled: true, + secretSource: "env", + }; + } + return null; + } + + // 解析账户配置 + if (accountId === "default") { + return { + accountId: "default", + appId: qqbot.appId || envAppId, + clientSecret: qqbot.clientSecret || envClientSecret, + enabled: qqbot.enabled ?? true, + secretSource: qqbot.clientSecret ? "config" : "env", + }; + } + + const accountConfig = qqbot.accounts?.[accountId]; + if (accountConfig) { + return { + accountId, + appId: accountConfig.appId || qqbot.appId || envAppId, + clientSecret: accountConfig.clientSecret || qqbot.clientSecret || envClientSecret, + enabled: accountConfig.enabled ?? true, + secretSource: accountConfig.clientSecret ? "config" : "env", + }; + } + + return null; + } catch { + return null; + } +} + +// 加载配置(用于 broadcastMessage) +function loadConfig(): Record { + const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json"); + try { + if (fs.existsSync(configPath)) { + return JSON.parse(fs.readFileSync(configPath, "utf-8")); + } + } catch {} + return {}; +} + +// 解析请求体 +async function parseBody(req: http.IncomingMessage): Promise> { + return new Promise((resolve) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch { + resolve({}); + } + }); + }); +} + +// 发送 JSON 响应 +function sendJson(res: http.ServerResponse, statusCode: number, data: unknown) { + res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(data, null, 2)); +} + +// 处理请求 +async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { + const parsedUrl = url.parse(req.url || "", true); + const pathname = parsedUrl.pathname || "/"; + const method = req.method || "GET"; + const query = parsedUrl.query; + + // CORS 支持 + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + console.log(`[${new Date().toISOString()}] ${method} ${pathname}`); + + try { + // POST /send - 发送主动消息 + if (pathname === "/send" && method === "POST") { + const body = await parseBody(req); + const { to, text, type = "c2c", accountId = "default" } = body as { + to?: string; + text?: string; + type?: "c2c" | "group"; + accountId?: string; + }; + + if (!to || !text) { + sendJson(res, 400, { error: "Missing required fields: to, text" }); + return; + } + + const account = loadAccount(accountId); + if (!account) { + sendJson(res, 500, { error: "Failed to load account configuration" }); + return; + } + + const result = await sendProactiveMessageDirect(account, to, text, type); + sendJson(res, result.success ? 200 : 500, result); + return; + } + + // GET /users - 列出已知用户 + if (pathname === "/users" && method === "GET") { + const type = query.type as "c2c" | "group" | "channel" | undefined; + const accountId = query.accountId as string | undefined; + const limit = query.limit ? parseInt(query.limit as string, 10) : undefined; + + const users = listKnownUsers({ type, accountId, limit }); + sendJson(res, 200, { total: users.length, users }); + return; + } + + // GET /users/stats - 获取用户统计 + if (pathname === "/users/stats" && method === "GET") { + const accountId = query.accountId as string | undefined; + const stats = getKnownUsersStats(accountId); + sendJson(res, 200, stats); + return; + } + + // GET /users/:openid - 获取单个用户 + if (pathname.startsWith("/users/") && method === "GET" && pathname !== "/users/stats") { + const openid = pathname.slice("/users/".length); + const type = (query.type as string) || "c2c"; + const accountId = (query.accountId as string) || "default"; + + const user = getKnownUser(type, openid, accountId); + if (user) { + sendJson(res, 200, user); + } else { + sendJson(res, 404, { error: "User not found" }); + } + return; + } + + // POST /broadcast - 广播消息 + if (pathname === "/broadcast" && method === "POST") { + const body = await parseBody(req); + const { text, type = "c2c", accountId, limit } = body as { + text?: string; + type?: "c2c" | "group"; + accountId?: string; + limit?: number; + }; + + if (!text) { + sendJson(res, 400, { error: "Missing required field: text" }); + return; + } + + const cfg = loadConfig(); + const result = await broadcastMessage(text, cfg as any, { type, accountId, limit }); + sendJson(res, 200, result); + return; + } + + // GET / - API 文档 + if (pathname === "/" && method === "GET") { + sendJson(res, 200, { + name: "QQBot Proactive Message API", + version: "1.0.0", + endpoints: { + "POST /send": { + description: "发送主动消息", + body: { + to: "目标 openid (必需)", + text: "消息内容 (必需)", + type: "消息类型: c2c | group (默认 c2c)", + accountId: "账户 ID (默认 default)", + }, + }, + "GET /users": { + description: "列出已知用户", + query: { + type: "过滤类型: c2c | group | channel", + accountId: "过滤账户 ID", + limit: "限制返回数量", + }, + }, + "GET /users/stats": { + description: "获取用户统计", + query: { + accountId: "过滤账户 ID", + }, + }, + "GET /users/:openid": { + description: "获取单个用户信息", + query: { + type: "用户类型 (默认 c2c)", + accountId: "账户 ID (默认 default)", + }, + }, + "POST /broadcast": { + description: "广播消息给所有已知用户", + body: { + text: "消息内容 (必需)", + type: "消息类型: c2c | group (默认 c2c)", + accountId: "账户 ID", + limit: "限制发送数量", + }, + }, + }, + notes: [ + "只有曾经与机器人交互过的用户才能收到主动消息", + ], + }); + return; + } + + // 404 + sendJson(res, 404, { error: "Not found" }); + } catch (err) { + console.error(`Error handling request: ${err}`); + sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }); + } +} + +// 解析命令行参数获取端口 +function getPort(): number { + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i++) { + if (args[i] === "--port" && args[i + 1]) { + return parseInt(args[i + 1], 10) || DEFAULT_PORT; + } + } + return parseInt(process.env.PROACTIVE_API_PORT || "", 10) || DEFAULT_PORT; +} + +// 启动服务器 +function main() { + const port = getPort(); + + const server = http.createServer(handleRequest); + + server.listen(port, () => { + console.log(` +╔═══════════════════════════════════════════════════════════════╗ +║ QQBot Proactive Message API Server ║ +╠═══════════════════════════════════════════════════════════════╣ +║ Server running at: http://localhost:${port.toString().padEnd(25)}║ +║ ║ +║ Endpoints: ║ +║ GET / - API documentation ║ +║ POST /send - Send proactive message ║ +║ GET /users - List known users ║ +║ GET /users/stats - Get user statistics ║ +║ POST /broadcast - Broadcast message ║ +║ ║ +║ Example: ║ +║ curl -X POST http://localhost:${port}/send \\ ║ +║ -H "Content-Type: application/json" \\ ║ +║ -d '{"to":"openid","text":"Hello!"}' ║ +╚═══════════════════════════════════════════════════════════════╝ +`); + }); + + // 优雅关闭 + process.on("SIGINT", () => { + console.log("\nShutting down..."); + server.close(() => { + process.exit(0); + }); + }); +} + +main(); diff --git a/scripts/send-proactive.ts b/scripts/send-proactive.ts new file mode 100644 index 0000000..0ebd5f0 --- /dev/null +++ b/scripts/send-proactive.ts @@ -0,0 +1,273 @@ +#!/usr/bin/env npx ts-node +/** + * QQBot 主动消息 CLI 工具 + * + * 使用示例: + * # 发送私聊消息 + * npx ts-node scripts/send-proactive.ts --to "用户openid" --text "你好!" + * + * # 发送群聊消息 + * npx ts-node scripts/send-proactive.ts --to "群组openid" --type group --text "群公告" + * + * # 列出已知用户 + * npx ts-node scripts/send-proactive.ts --list + * + * # 列出群聊用户 + * npx ts-node scripts/send-proactive.ts --list --type group + * + * # 广播消息 + * npx ts-node scripts/send-proactive.ts --broadcast --text "系统公告" --type c2c --limit 10 + */ + +import { + sendProactiveMessageDirect, + listKnownUsers, + getKnownUsersStats, + broadcastMessage, +} from "../src/proactive.js"; +import type { ResolvedQQBotAccount } from "../src/types.js"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +// 解析命令行参数 +function parseArgs(): Record { + const args: Record = {}; + const argv = process.argv.slice(2); + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg.startsWith("--")) { + const key = arg.slice(2); + const nextArg = argv[i + 1]; + if (nextArg && !nextArg.startsWith("--")) { + args[key] = nextArg; + i++; + } else { + args[key] = true; + } + } + } + + return args; +} + +// 从配置文件加载账户信息 +function loadAccount(accountId = "default"): ResolvedQQBotAccount | null { + const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json"); + + try { + if (!fs.existsSync(configPath)) { + // 尝试从环境变量获取 + const appId = process.env.QQBOT_APP_ID; + const clientSecret = process.env.QQBOT_CLIENT_SECRET; + + if (appId && clientSecret) { + return { + accountId, + appId, + clientSecret, + enabled: true, + secretSource: "env", + }; + } + + console.error("配置文件不存在且环境变量未设置"); + return null; + } + + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + const qqbot = config.channels?.qqbot; + + if (!qqbot) { + console.error("配置中没有 qqbot 配置"); + return null; + } + + // 解析账户配置 + if (accountId === "default") { + return { + accountId: "default", + appId: qqbot.appId || process.env.QQBOT_APP_ID, + clientSecret: qqbot.clientSecret || process.env.QQBOT_CLIENT_SECRET, + enabled: qqbot.enabled ?? true, + secretSource: qqbot.clientSecret ? "config" : "env", + }; + } + + const accountConfig = qqbot.accounts?.[accountId]; + if (accountConfig) { + return { + accountId, + appId: accountConfig.appId || qqbot.appId || process.env.QQBOT_APP_ID, + clientSecret: accountConfig.clientSecret || qqbot.clientSecret || process.env.QQBOT_CLIENT_SECRET, + enabled: accountConfig.enabled ?? true, + secretSource: accountConfig.clientSecret ? "config" : "env", + }; + } + + console.error(`账户 ${accountId} 不存在`); + return null; + } catch (err) { + console.error(`加载配置失败: ${err}`); + return null; + } +} + +async function main() { + const args = parseArgs(); + + // 显示帮助 + if (args.help || args.h) { + console.log(` +QQBot 主动消息 CLI 工具 + +用法: + npx ts-node scripts/send-proactive.ts [选项] + +选项: + --to 目标用户或群组的 openid + --text 要发送的消息内容 + --type 消息类型: c2c (私聊) 或 group (群聊),默认 c2c + --account 账户 ID,默认 default + + --list 列出已知用户 + --stats 显示用户统计 + --broadcast 广播消息给所有已知用户 + --limit 限制数量 + + --help, -h 显示帮助 + +示例: + # 发送私聊消息 + npx ts-node scripts/send-proactive.ts --to "0Eda5EA7-xxx" --text "你好!" + + # 发送群聊消息 + npx ts-node scripts/send-proactive.ts --to "A1B2C3D4" --type group --text "群公告" + + # 列出最近 10 个私聊用户 + npx ts-node scripts/send-proactive.ts --list --type c2c --limit 10 + + # 广播消息 + npx ts-node scripts/send-proactive.ts --broadcast --text "系统公告" --limit 5 +`); + return; + } + + const accountId = (args.account as string) || "default"; + const type = (args.type as "c2c" | "group") || "c2c"; + const limit = args.limit ? parseInt(args.limit as string, 10) : undefined; + + // 列出已知用户 + if (args.list) { + const users = listKnownUsers({ + type: args.type as "c2c" | "group" | "channel" | undefined, + accountId: args.account as string | undefined, + limit, + }); + + if (users.length === 0) { + console.log("没有已知用户"); + return; + } + + console.log(`\n已知用户列表 (共 ${users.length} 个):\n`); + console.log("类型\t\tOpenID\t\t\t\t\t\t昵称\t\t最后交互时间"); + console.log("─".repeat(100)); + + for (const user of users) { + const lastTime = new Date(user.lastInteractionAt).toLocaleString(); + console.log(`${user.type}\t\t${user.openid.slice(0, 20)}...\t${user.nickname || "-"}\t\t${lastTime}`); + } + return; + } + + // 显示统计 + if (args.stats) { + const stats = getKnownUsersStats(args.account as string | undefined); + console.log(`\n用户统计:`); + console.log(` 总计: ${stats.total}`); + console.log(` 私聊: ${stats.c2c}`); + console.log(` 群聊: ${stats.group}`); + console.log(` 频道: ${stats.channel}`); + return; + } + + // 广播消息 + if (args.broadcast) { + if (!args.text) { + console.error("请指定消息内容 (--text)"); + process.exit(1); + } + + // 加载配置用于广播 + const configPath = path.join(process.env.HOME || "/home/ubuntu", "clawd", "config.json"); + let cfg: Record = {}; + try { + if (fs.existsSync(configPath)) { + cfg = JSON.parse(fs.readFileSync(configPath, "utf-8")); + } + } catch {} + + console.log(`\n开始广播消息...\n`); + const result = await broadcastMessage(args.text as string, cfg as any, { + type, + accountId, + limit, + }); + + console.log(`\n广播完成:`); + console.log(` 发送总数: ${result.total}`); + console.log(` 成功: ${result.success}`); + console.log(` 失败: ${result.failed}`); + + if (result.failed > 0) { + console.log(`\n失败详情:`); + for (const r of result.results) { + if (!r.result.success) { + console.log(` ${r.to}: ${r.result.error}`); + } + } + } + return; + } + + // 发送单条消息 + if (args.to && args.text) { + const account = loadAccount(accountId); + if (!account) { + console.error("无法加载账户配置"); + process.exit(1); + } + + console.log(`\n发送消息...`); + console.log(` 目标: ${args.to}`); + console.log(` 类型: ${type}`); + console.log(` 内容: ${args.text}`); + + const result = await sendProactiveMessageDirect( + account, + args.to as string, + args.text as string, + type + ); + + if (result.success) { + console.log(`\n✅ 发送成功!`); + console.log(` 消息ID: ${result.messageId}`); + console.log(` 时间戳: ${result.timestamp}`); + } else { + console.log(`\n❌ 发送失败: ${result.error}`); + process.exit(1); + } + return; + } + + // 没有有效参数 + console.error("请指定操作。使用 --help 查看帮助。"); + process.exit(1); +} + +main().catch((err) => { + console.error(`执行失败: ${err}`); + process.exit(1); +}); diff --git a/skills/qqbot-cron/SKILL.md b/skills/qqbot-cron/SKILL.md new file mode 100644 index 0000000..5de8bc0 --- /dev/null +++ b/skills/qqbot-cron/SKILL.md @@ -0,0 +1,476 @@ +--- +name: qqbot-cron +description: QQ Bot 智能提醒技能。支持一次性提醒、周期性任务、自动降级确保送达。可设置、查询、取消提醒。 +metadata: {"clawdbot":{"emoji":"⏰","requires":{"channels":["qqbot"]}}} +--- + +# QQ Bot 智能提醒 + +让 AI 帮用户设置、管理定时提醒,支持私聊和群聊。 + +--- + +## 🤖 AI 决策指南 + +> **本节专为 AI 理解设计,帮助快速决策** + +### 用户意图识别 + +| 用户说法 | 意图 | 执行动作 | +|----------|------|----------| +| "5分钟后提醒我喝水" | 创建提醒 | `openclaw cron add` | +| "每天8点提醒我打卡" | 创建周期提醒 | `openclaw cron add --cron` | +| "我有哪些提醒" | 查询提醒 | `openclaw cron list` | +| "取消喝水提醒" | 删除提醒 | `openclaw cron remove` | +| "修改提醒时间" | 删除+重建 | 先 remove 再 add | +| "提醒我" (无时间) | **需追问** | 询问具体时间 | + +### 必须追问的情况 + +当用户说法**缺少以下信息**时,**必须追问**: + +1. **没有时间**:"提醒我喝水" → 询问"请问什么时候提醒你?" +2. **时间模糊**:"晚点提醒我" → 询问"具体几点呢?" +3. **周期不明**:"定期提醒我" → 询问"多久一次?每天?每周?" + +### 无需追问可直接执行 + +| 用户说法 | 理解为 | +|----------|--------| +| "5分钟后" | `--at 5m` | +| "半小时后" | `--at 30m` | +| "1小时后" | `--at 1h` | +| "明天早上8点" | `--at 2026-02-02T08:00:00+08:00` | +| "每天早上8点" | `--cron "0 8 * * *"` | +| "工作日9点" | `--cron "0 9 * * 1-5"` | + +--- + +## 📋 命令速查 + +### 创建提醒(完整模板) + +```bash +openclaw cron add \ + --name "{任务名}" \ + --at "{时间}" \ + --session isolated \ + --system-event '{"type":"reminder","user_openid":"{openid}","user_name":"{用户名称}","original_message_id":"{message_id}","reminder_content":"{提醒内容}","created_at":"{当前时间ISO格式}"}' \ + --message "{消息内容}" \ + --deliver \ + --channel qqbot \ + --to "{openid}" \ + --delete-after-run +``` + +> 💡 **`--system-event` 说明**:用于存储用户上下文信息,提醒触发时 AI 可以获取这些信息来提供更个性化的提醒。 + +> ⚠️ **注意**:`cron add` 命令不支持 `--reply-to` 参数。提醒消息将作为主动消息直接发送给用户。 + +### 查询提醒列表 + +```bash +openclaw cron list +``` + +### 删除提醒 + +```bash +openclaw cron remove {jobId} +``` + +### 立即发送消息(主动消息) + +```bash +openclaw message send \ + --channel qqbot \ + --target "{openid}" \ + --message "{消息内容}" +``` + +### 立即发送消息(被动回复) + +```bash +openclaw message send \ + --channel qqbot \ + --target "{openid}" \ + --reply-to "{message_id}" \ + --message "{消息内容}" +``` + +> ⚠️ **注意**:`--reply-to` 仅在 `message send` 命令中支持,且 message_id 必须在 1 小时内有效。定时提醒不支持被动回复。 + +--- + +## 💬 用户交互模板 + +> **创建提醒后,必须给用户反馈确认** + +### 创建成功反馈 + +**一次性提醒**: +``` +✅ 提醒已设置! + +📝 内容:{提醒内容} +⏰ 时间:{具体时间} + +到时候我会准时提醒你~ +``` + +**周期提醒**: +``` +✅ 周期提醒已设置! + +📝 内容:{提醒内容} +🔄 周期:{周期描述,如"每天早上8:00"} + +提醒会持续生效,说"取消xx提醒"可停止~ +``` + +### 查询提醒反馈 + +``` +📋 你当前的提醒: + +1. ⏰ 喝水提醒 - 5分钟后 (一次性) +2. 🔄 打卡提醒 - 每天08:00 (周期) +3. 🔄 日报提醒 - 工作日18:00 (周期) + +说"取消xx提醒"可删除~ +``` + +### 无提醒时反馈 + +``` +📋 你当前没有设置任何提醒 + +说"5分钟后提醒我xxx"可创建提醒~ +``` + +### 删除成功反馈 + +``` +✅ 已取消"{提醒名称}"提醒 +``` + +--- + +## ⏱️ 时间格式 + +### 相对时间(--at) + +> ⚠️ **不要加 + 号!** 用 `5m` 而不是 `+5m` + +| 用户说法 | 参数值 | +|----------|--------| +| 5分钟后 | `5m` | +| 半小时后 | `30m` | +| 1小时后 | `1h` | +| 2小时后 | `2h` | +| 明天这时候 | `24h` | + +### 绝对时间(--at) + +| 用户说法 | 参数值 | +|----------|--------| +| 今天下午3点 | `2026-02-01T15:00:00+08:00` | +| 明天早上8点 | `2026-02-02T08:00:00+08:00` | +| 2月14日中午 | `2026-02-14T12:00:00+08:00` | + +### Cron 表达式(--cron) + +| 用户说法 | Cron 表达式 | 必须加 `--tz "Asia/Shanghai"` | +|----------|-------------|------------------------------| +| 每天早上8点 | `0 8 * * *` | ✅ | +| 每天晚上10点 | `0 22 * * *` | ✅ | +| 每个工作日早上9点 | `0 9 * * 1-5` | ✅ | +| 每周一早上9点 | `0 9 * * 1` | ✅ | +| 每周末上午10点 | `0 10 * * 0,6` | ✅ | +| 每小时整点 | `0 * * * *` | ✅ | + +--- + +## 📌 参数说明 + +### 必填参数 + +| 参数 | 说明 | 示例 | +|------|------|------| +| `--name` | 任务名,含用户标识 | `"喝水提醒-小明"` | +| `--at` / `--cron` | 触发时间(二选一) | `5m` / `0 8 * * *` | +| `--session isolated` | 隔离会话 | 固定值 | +| `--message` | 消息内容,**不能为空** | `"💧 该喝水啦!"` | +| `--deliver` | 启用投递 | 固定值 | +| `--channel qqbot` | QQ 渠道 | 固定值 | +| `--to` | 接收者 openid | 从系统消息获取 | + +### 推荐参数(用于存储用户上下文) + +| 参数 | 说明 | 何时使用 | +|------|------|----------| +| `--system-event` | 用户上下文 JSON | **建议所有任务都使用** | +| `--delete-after-run` | 执行后删除 | **一次性任务必须** | +| `--tz "Asia/Shanghai"` | 时区 | **周期任务必须** | + +### --system-event 字段说明 + +`--system-event` 用于存储提醒的上下文信息,格式为 JSON: + +```json +{ + "type": "reminder", + "user_openid": "用户的openid", + "user_name": "用户名称(如有)", + "original_message_id": "创建提醒时的message_id", + "reminder_content": "提醒内容摘要", + "created_at": "创建时间ISO格式" +} +``` + +| 字段 | 说明 | 来源 | +|------|------|------| +| `type` | 事件类型,固定为 `"reminder"` | 固定值 | +| `user_openid` | 用户 openid | 从系统消息获取 | +| `user_name` | 用户名称 | 从系统消息获取(如有) | +| `original_message_id` | 创建时的消息 ID | 从系统消息获取 | +| `reminder_content` | 提醒内容摘要 | AI 根据用户请求生成 | +| `created_at` | 提醒创建时间 | 当前时间 ISO 格式 | + +> 💡 **为什么需要 `--system-event`?** +> - 提醒触发时,AI 可以获取完整的用户上下文 +> - 便于追踪提醒来源和调试 +> - 为未来可能的功能扩展预留信息 + +--- + +## 🎯 使用场景示例 + +### 场景1:一次性提醒 + +**用户**: 5分钟后提醒我喝水 + +**AI 执行**: +```bash +openclaw cron add \ + --name "喝水提醒-用户" \ + --at "5m" \ + --session isolated \ + --system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"喝水","created_at":"2026-02-01T16:50:00+08:00"}' \ + --message "💧 该喝水啦!这是你5分钟前设置的提醒~" \ + --deliver \ + --channel qqbot \ + --to "{openid}" \ + --delete-after-run +``` + +**AI 回复**: +``` +✅ 提醒已设置! + +📝 内容:喝水 +⏰ 时间:5分钟后 + +到时候我会准时提醒你~ +``` + +--- + +### 场景2:每日周期提醒 + +**用户**: 每天早上8点提醒我打卡 + +**AI 执行**: +```bash +openclaw cron add \ + --name "打卡提醒-用户" \ + --cron "0 8 * * *" \ + --tz "Asia/Shanghai" \ + --session isolated \ + --system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"打卡","created_at":"2026-02-01T16:50:00+08:00"}' \ + --message "🌅 早上好!记得打卡签到~" \ + --deliver \ + --channel qqbot \ + --to "{openid}" +``` + +**AI 回复**: +``` +✅ 周期提醒已设置! + +📝 内容:打卡 +🔄 周期:每天早上 8:00 + +提醒会持续生效,说"取消打卡提醒"可停止~ +``` + +> 💡 周期任务**不加** `--delete-after-run` + +--- + +### 场景3:工作日提醒 + +**用户**: 工作日下午6点提醒我写日报 + +**AI 执行**: +```bash +openclaw cron add \ + --name "日报提醒-用户" \ + --cron "0 18 * * 1-5" \ + --tz "Asia/Shanghai" \ + --session isolated \ + --system-event '{"type":"reminder","user_openid":"{openid}","original_message_id":"{message_id}","reminder_content":"写日报","created_at":"2026-02-01T16:50:00+08:00"}' \ + --message "📝 今天的日报别忘了提交!" \ + --deliver \ + --channel qqbot \ + --to "{openid}" +``` + +--- + +### 场景4:群组提醒 + +**用户**(群聊): 每天早上9点提醒大家站会 + +**AI 执行**: +```bash +openclaw cron add \ + --name "站会提醒-群" \ + --cron "0 9 * * 1-5" \ + --tz "Asia/Shanghai" \ + --session isolated \ + --system-event '{"type":"reminder","user_openid":"group:{group_openid}","original_message_id":"{message_id}","reminder_content":"站会","created_at":"2026-02-01T16:50:00+08:00"}' \ + --message "📢 各位同事,9点站会时间到!请准时参加~" \ + --deliver \ + --channel qqbot \ + --to "group:{group_openid}" +``` + +> 💡 群组使用 `group:{group_openid}` 格式 + +--- + +### 场景5:查询提醒 + +**用户**: 我有哪些提醒? + +**AI 执行**: +```bash +openclaw cron list +``` + +**AI 回复**(根据返回结果组织): +``` +📋 你当前的提醒: + +1. ⏰ 喝水提醒 - 3分钟后 (一次性) +2. 🔄 打卡提醒 - 每天08:00 (周期) + +说"取消xx提醒"可删除~ +``` + +--- + +### 场景6:取消提醒 + +**用户**: 取消打卡提醒 + +**AI 执行**: +1. 先执行 `openclaw cron list` 找到对应任务 ID +2. 执行 `openclaw cron remove {jobId}` + +**AI 回复**: +``` +✅ 已取消"打卡提醒" +``` + +--- + +## ⚙️ 消息发送说明 + +### 定时提醒(cron add) + +定时提醒**只能发送主动消息**,因为: +- 提醒执行时,原始 message_id 通常已超过 1 小时有效期 +- `openclaw cron add` 命令不支持 `--reply-to` 参数 + +``` +┌─────────────────────┐ +│ 定时任务触发 │ +└──────────┬──────────┘ + ↓ +┌─────────────────────┐ +│ AI 通过 system-event │ +│ 获取用户上下文信息 │ +└──────────┬──────────┘ + ↓ +┌─────────────────────┐ +│ 发送主动消息到用户 │ +│ --channel qqbot │ +│ --to {openid} │ +└──────────┬──────────┘ + ↓ + ✅ 用户收到提醒 +``` + +### 即时回复(message send) + +即时消息发送支持被动回复(如果 message_id 有效): + +``` + ┌─────────────────────┐ + │ 发送即时消息 │ + └──────────┬──────────┘ + ↓ + ┌──────────────────────────────┐ + │ 有 --reply-to 且 message_id │ + │ 在 1 小时内有效? │ + └──────────────────────────────┘ + ↓ ↓ + 是 否 + ↓ ↓ + ┌───────────────┐ ┌─────────────────┐ + │ 被动消息回复 │ │ 发送主动消息 │ + │ (引用原消息) │ │ (直接发送) │ + └───────────────┘ └─────────────────┘ +``` + +--- + +## ⚠️ 重要限制 + +| 限制 | 说明 | +|------|------| +| **message_id 有效期** | 1 小时内有效,超时自动降级 | +| **回复次数限制** | 同一 message_id 最多回复 4 次 | +| **主动消息限制** | 只能发给与机器人交互过的用户 | +| **消息内容** | `--message` 不能为空 | + +--- + +## 📝 消息模板 + +| 场景 | 模板 | Emoji | +|------|------|-------| +| 喝水 | 该喝水啦! | 💧 🚰 | +| 打卡 | 早上好!记得打卡~ | 🌅 ✅ | +| 会议 | xx会议马上开始! | 📅 👥 | +| 休息 | 该休息一下了~ | 😴 💤 | +| 日报 | 今日日报别忘了~ | 📝 ✍️ | +| 运动 | 该运动了! | 🏃 💪 | +| 吃药 | 记得按时吃药~ | 💊 🏥 | +| 生日 | 今天是xx的生日! | 🎂 🎉 | + +--- + +## 🔧 用户标识 + +| 类型 | 格式 | 来源 | +|------|------|------| +| 用户 openid | `B3EA9A1d-2D3c-5CBD-...` | 系统消息自动提供 | +| 群组 openid | `group:FeC1ADaf-...` | 系统消息自动提供 | +| message_id | `ROBOT1.0_xxx` | 系统消息自动提供 | + +> 💡 这些信息在系统消息中格式如: +> - `当前用户 openid: B3EA9A1d-...` +> - `当前消息 message_id: ROBOT1.0_...` \ No newline at end of file diff --git a/src/api.ts b/src/api.ts index 106d5e9..c517782 100644 --- a/src/api.ts +++ b/src/api.ts @@ -26,9 +26,14 @@ export function isMarkdownSupport(): boolean { } let cachedToken: { token: string; expiresAt: number } | null = null; +// Singleflight: 防止并发获取 Token 的 Promise 缓存 +let tokenFetchPromise: Promise | null = null; /** - * 获取 AccessToken(带缓存) + * 获取 AccessToken(带缓存 + singleflight 并发安全) + * + * 使用 singleflight 模式:当多个请求同时发现 Token 过期时, + * 只有第一个请求会真正去获取新 Token,其他请求复用同一个 Promise。 */ export async function getAccessToken(appId: string, clientSecret: string): Promise { // 检查缓存,提前 5 分钟刷新 @@ -36,6 +41,30 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi return cachedToken.token; } + // Singleflight: 如果已有进行中的 Token 获取请求,复用它 + if (tokenFetchPromise) { + console.log(`[qqbot-api] Token fetch in progress, waiting for existing request...`); + return tokenFetchPromise; + } + + // 创建新的 Token 获取 Promise(singleflight 入口) + tokenFetchPromise = (async () => { + try { + return await doFetchToken(appId, clientSecret); + } finally { + // 无论成功失败,都清除 Promise 缓存 + tokenFetchPromise = null; + } + })(); + + return tokenFetchPromise; +} + +/** + * 实际执行 Token 获取的内部函数 + */ +async function doFetchToken(appId: string, clientSecret: string): Promise { + const requestBody = { appId, clientSecret }; const requestHeaders = { "Content-Type": "application/json" }; @@ -86,6 +115,7 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000, }; + console.log(`[qqbot-api] Token cached, expires at: ${new Date(cachedToken.expiresAt).toISOString()}`); return cachedToken.token; } @@ -94,6 +124,22 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi */ export function clearTokenCache(): void { cachedToken = null; + // 注意:不清除 tokenFetchPromise,让进行中的请求完成 + // 下次调用 getAccessToken 时会自动获取新 Token +} + +/** + * 获取 Token 缓存状态(用于监控) + */ +export function getTokenStatus(): { status: "valid" | "expired" | "refreshing" | "none"; expiresAt: number | null } { + if (tokenFetchPromise) { + return { status: "refreshing", expiresAt: cachedToken?.expiresAt ?? null }; + } + if (!cachedToken) { + return { status: "none", expiresAt: null }; + } + const isValid = Date.now() < cachedToken.expiresAt - 5 * 60 * 1000; + return { status: isValid ? "valid" : "expired", expiresAt: cachedToken.expiresAt }; } /** @@ -320,32 +366,65 @@ export async function sendGroupMessage( return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body); } +/** + * 构建主动消息请求体 + * 根据 markdownSupport 配置决定消息格式: + * - markdown 模式: { markdown: { content }, msg_type: 2 } + * - 纯文本模式: { content, msg_type: 0 } + * + * 注意:主动消息不支持流式发送 + */ +function buildProactiveMessageBody(content: string): Record { + // 主动消息内容校验(参考 Telegram 机制) + if (!content || content.trim().length === 0) { + throw new Error("主动消息内容不能为空 (markdown.content is empty)"); + } + + if (currentMarkdownSupport) { + return { + markdown: { content }, + msg_type: 2, + }; + } else { + return { + content, + msg_type: 0, + }; + } +} + /** * 主动发送 C2C 单聊消息(不需要 msg_id,每月限 4 条/用户) + * + * 注意: + * 1. 内容不能为空(对应 markdown.content 字段) + * 2. 不支持流式发送 */ export async function sendProactiveC2CMessage( accessToken: string, openid: string, content: string ): Promise<{ id: string; timestamp: number }> { - return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, { - content, - msg_type: 0, - }); + const body = buildProactiveMessageBody(content); + console.log(`[qqbot-api] sendProactiveC2CMessage: openid=${openid}, msg_type=${body.msg_type}, content_len=${content.length}`); + return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body); } /** * 主动发送群聊消息(不需要 msg_id,每月限 4 条/群) + * + * 注意: + * 1. 内容不能为空(对应 markdown.content 字段) + * 2. 不支持流式发送 */ export async function sendProactiveGroupMessage( accessToken: string, groupOpenid: string, content: string ): Promise<{ id: string; timestamp: string }> { - return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, { - content, - msg_type: 0, - }); + const body = buildProactiveMessageBody(content); + console.log(`[qqbot-api] sendProactiveGroupMessage: group=${groupOpenid}, msg_type=${body.msg_type}, content_len=${content.length}`); + return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body); } // ============ 富媒体消息支持 ============ @@ -475,3 +554,150 @@ export async function sendGroupImageMessage( // 再发送富媒体消息 return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content); } + +// ============ 后台 Token 刷新 (P1-1) ============ + +/** + * 后台 Token 刷新配置 + */ +interface BackgroundTokenRefreshOptions { + /** 提前刷新时间(毫秒,默认 5 分钟) */ + refreshAheadMs?: number; + /** 随机偏移范围(毫秒,默认 0-30 秒) */ + randomOffsetMs?: number; + /** 最小刷新间隔(毫秒,默认 1 分钟) */ + minRefreshIntervalMs?: number; + /** 失败后重试间隔(毫秒,默认 5 秒) */ + retryDelayMs?: number; + /** 日志函数 */ + log?: { + info: (msg: string) => void; + error: (msg: string) => void; + debug?: (msg: string) => void; + }; +} + +// 后台刷新状态 +let backgroundRefreshRunning = false; +let backgroundRefreshAbortController: AbortController | null = null; + +/** + * 启动后台 Token 刷新 + * 在后台定时刷新 Token,避免请求时才发现过期 + * + * @param appId 应用 ID + * @param clientSecret 应用密钥 + * @param options 配置选项 + */ +export function startBackgroundTokenRefresh( + appId: string, + clientSecret: string, + options?: BackgroundTokenRefreshOptions +): void { + if (backgroundRefreshRunning) { + console.log("[qqbot-api] Background token refresh already running"); + return; + } + + const { + refreshAheadMs = 5 * 60 * 1000, // 提前 5 分钟刷新 + randomOffsetMs = 30 * 1000, // 0-30 秒随机偏移 + minRefreshIntervalMs = 60 * 1000, // 最少 1 分钟后刷新 + retryDelayMs = 5 * 1000, // 失败后 5 秒重试 + log, + } = options ?? {}; + + backgroundRefreshRunning = true; + backgroundRefreshAbortController = new AbortController(); + const signal = backgroundRefreshAbortController.signal; + + const refreshLoop = async () => { + log?.info?.("[qqbot-api] Background token refresh started"); + + while (!signal.aborted) { + try { + // 先确保有一个有效 Token + await getAccessToken(appId, clientSecret); + + // 计算下次刷新时间 + if (cachedToken) { + const expiresIn = cachedToken.expiresAt - Date.now(); + // 提前刷新时间 + 随机偏移(避免集群同时刷新) + const randomOffset = Math.random() * randomOffsetMs; + const refreshIn = Math.max( + expiresIn - refreshAheadMs - randomOffset, + minRefreshIntervalMs + ); + + log?.debug?.( + `[qqbot-api] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s` + ); + + // 等待到刷新时间 + await sleep(refreshIn, signal); + } else { + // 没有缓存的 Token,等待一段时间后重试 + log?.debug?.("[qqbot-api] No cached token, retrying soon"); + await sleep(minRefreshIntervalMs, signal); + } + } catch (err) { + if (signal.aborted) break; + + // 刷新失败,等待后重试 + log?.error?.(`[qqbot-api] Background token refresh failed: ${err}`); + await sleep(retryDelayMs, signal); + } + } + + backgroundRefreshRunning = false; + log?.info?.("[qqbot-api] Background token refresh stopped"); + }; + + // 异步启动,不阻塞调用者 + refreshLoop().catch((err) => { + backgroundRefreshRunning = false; + log?.error?.(`[qqbot-api] Background token refresh crashed: ${err}`); + }); +} + +/** + * 停止后台 Token 刷新 + */ +export function stopBackgroundTokenRefresh(): void { + if (backgroundRefreshAbortController) { + backgroundRefreshAbortController.abort(); + backgroundRefreshAbortController = null; + } + backgroundRefreshRunning = false; +} + +/** + * 检查后台 Token 刷新是否正在运行 + */ +export function isBackgroundTokenRefreshRunning(): boolean { + return backgroundRefreshRunning; +} + +/** + * 可中断的 sleep 函数 + */ +async function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + + if (signal) { + if (signal.aborted) { + clearTimeout(timer); + reject(new Error("Aborted")); + return; + } + + const onAbort = () => { + clearTimeout(timer); + reject(new Error("Aborted")); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + } + }); +} diff --git a/src/config.ts b/src/config.ts index f898941..0bc1722 100644 --- a/src/config.ts +++ b/src/config.ts @@ -77,6 +77,7 @@ export function resolveQQBotAccount( systemPrompt: qqbot?.systemPrompt, imageServerBaseUrl: qqbot?.imageServerBaseUrl, markdownSupport: qqbot?.markdownSupport, + streamEnabled: qqbot?.streamEnabled, }; appId = qqbot?.appId ?? ""; } else { @@ -113,6 +114,7 @@ export function resolveQQBotAccount( systemPrompt: accountConfig.systemPrompt, imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL, markdownSupport: accountConfig.markdownSupport, + streamEnabled: accountConfig.streamEnabled, config: accountConfig, }; } diff --git a/src/gateway.ts b/src/gateway.ts index 95f1df1..68fe910 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -2,7 +2,9 @@ import WebSocket from "ws"; import path from "node:path"; import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js"; import { StreamState } from "./types.js"; -import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, sendC2CInputNotify } from "./api.js"; +import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh } from "./api.js"; +import { loadSession, saveSession, clearSession, type SessionState } from "./session-store.js"; +import { recordKnownUser, flushKnownUsers } from "./known-users.js"; import { getQQBotRuntime } from "./runtime.js"; import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js"; import { createStreamSender } from "./outbound.js"; @@ -55,7 +57,79 @@ const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || path.join(process // 流式消息配置 const STREAM_CHUNK_INTERVAL = 500; // 流式消息分片间隔(毫秒) const STREAM_MIN_CHUNK_SIZE = 10; // 最小分片大小(字符) -const STREAM_KEEPALIVE_INTERVAL = 8000; // 流式心跳间隔(毫秒),需要在 10 秒内发送 +const STREAM_KEEPALIVE_FIRST_DELAY = 3000; // 首次状态保持延迟(毫秒),openclaw 3s 内未回复时发送 +const STREAM_KEEPALIVE_GAP = 10000; // 状态保持消息之间的间隔(毫秒) +const STREAM_KEEPALIVE_MAX_PER_CHUNK = 2; // 每 2 个消息分片之间最多发送的状态保持消息数量 +const STREAM_MAX_DURATION = 3 * 60 * 1000; // 流式消息最大持续时间(毫秒),超过 3 分钟自动结束 + +// 消息队列配置(异步处理,防止阻塞心跳) +const MESSAGE_QUEUE_SIZE = 1000; // 最大队列长度 +const MESSAGE_QUEUE_WARN_THRESHOLD = 800; // 队列告警阈值 + +// ============ 消息回复限流器 ============ +// 同一 message_id 1小时内最多回复 4 次,超过1小时需降级为主动消息 +const MESSAGE_REPLY_LIMIT = 4; +const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时 + +interface MessageReplyRecord { + count: number; + firstReplyAt: number; +} + +const messageReplyTracker = new Map(); + +/** + * 检查是否可以回复该消息(限流检查) + * @param messageId 消息ID + * @returns { allowed: boolean, remaining: number } allowed=是否允许回复,remaining=剩余次数 + */ +function checkMessageReplyLimit(messageId: string): { allowed: boolean; remaining: number } { + const now = Date.now(); + const record = messageReplyTracker.get(messageId); + + // 清理过期记录(定期清理,避免内存泄漏) + if (messageReplyTracker.size > 10000) { + for (const [id, rec] of messageReplyTracker) { + if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) { + messageReplyTracker.delete(id); + } + } + } + + if (!record) { + return { allowed: true, remaining: MESSAGE_REPLY_LIMIT }; + } + + // 检查是否过期 + if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) { + messageReplyTracker.delete(messageId); + return { allowed: true, remaining: MESSAGE_REPLY_LIMIT }; + } + + // 检查是否超过限制 + const remaining = MESSAGE_REPLY_LIMIT - record.count; + return { allowed: remaining > 0, remaining: Math.max(0, remaining) }; +} + +/** + * 记录一次消息回复 + * @param messageId 消息ID + */ +function recordMessageReply(messageId: string): void { + const now = Date.now(); + const record = messageReplyTracker.get(messageId); + + if (!record) { + messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now }); + } else { + // 检查是否过期,过期则重新计数 + if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) { + messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now }); + } else { + record.count++; + } + } +} export interface GatewayContext { account: ResolvedQQBotAccount; @@ -70,6 +144,22 @@ export interface GatewayContext { }; } +/** + * 消息队列项类型(用于异步处理消息,防止阻塞心跳) + */ +interface QueuedMessage { + type: "c2c" | "guild" | "dm" | "group"; + senderId: string; + senderName?: string; + content: string; + messageId: string; + timestamp: string; + channelId?: string; + guildId?: string; + groupOpenid?: string; + attachments?: Array<{ content_type: string; url: string; filename?: string }>; +} + /** * 启动图床服务器 */ @@ -137,6 +227,74 @@ export async function startGateway(ctx: GatewayContext): Promise { let intentLevelIndex = 0; // 当前尝试的权限级别索引 let lastSuccessfulIntentLevel = -1; // 上次成功的权限级别 + // ============ P1-2: 尝试从持久化存储恢复 Session ============ + const savedSession = loadSession(account.accountId); + if (savedSession) { + sessionId = savedSession.sessionId; + lastSeq = savedSession.lastSeq; + intentLevelIndex = savedSession.intentLevelIndex; + lastSuccessfulIntentLevel = savedSession.intentLevelIndex; + log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}, intentLevel=${intentLevelIndex}`); + } + + // ============ 消息队列(异步处理,防止阻塞心跳) ============ + const messageQueue: QueuedMessage[] = []; + let messageProcessorRunning = false; + let messagesProcessed = 0; // 统计已处理消息数 + + /** + * 将消息加入队列(非阻塞) + */ + const enqueueMessage = (msg: QueuedMessage): void => { + if (messageQueue.length >= MESSAGE_QUEUE_SIZE) { + // 队列满了,丢弃最旧的消息 + const dropped = messageQueue.shift(); + log?.error(`[qqbot:${account.accountId}] Message queue full, dropping oldest message from ${dropped?.senderId}`); + } + if (messageQueue.length >= MESSAGE_QUEUE_WARN_THRESHOLD) { + log?.info(`[qqbot:${account.accountId}] Message queue size: ${messageQueue.length}/${MESSAGE_QUEUE_SIZE}`); + } + messageQueue.push(msg); + log?.debug?.(`[qqbot:${account.accountId}] Message enqueued, queue size: ${messageQueue.length}`); + }; + + /** + * 启动消息处理循环(独立于 WS 消息循环) + */ + const startMessageProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise): void => { + if (messageProcessorRunning) return; + messageProcessorRunning = true; + + const processLoop = async () => { + while (!isAborted) { + if (messageQueue.length === 0) { + // 队列为空,等待一小段时间 + await new Promise(resolve => setTimeout(resolve, 50)); + continue; + } + + const msg = messageQueue.shift()!; + try { + await handleMessageFn(msg); + messagesProcessed++; + } catch (err) { + // 捕获处理异常,防止影响队列循环 + log?.error(`[qqbot:${account.accountId}] Message processor error: ${err}`); + } + } + messageProcessorRunning = false; + log?.info(`[qqbot:${account.accountId}] Message processor stopped`); + }; + + // 异步启动,不阻塞调用者 + processLoop().catch(err => { + log?.error(`[qqbot:${account.accountId}] Message processor crashed: ${err}`); + messageProcessorRunning = false; + }); + + log?.info(`[qqbot:${account.accountId}] Message processor started`); + }; + abortSignal.addEventListener("abort", () => { isAborted = true; if (reconnectTimer) { @@ -144,6 +302,10 @@ export async function startGateway(ctx: GatewayContext): Promise { reconnectTimer = null; } cleanup(); + // P1-1: 停止后台 Token 刷新 + stopBackgroundTokenRefresh(); + // P1-3: 保存已知用户数据 + flushKnownUsers(); }); const cleanup = () => { @@ -232,16 +394,9 @@ export async function startGateway(ctx: GatewayContext): Promise { log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`); } - // 对于 C2C 消息,先发送输入状态提示用户机器人正在输入 - if (event.type === "c2c") { - try { - const token = await getAccessToken(account.appId, account.clientSecret); - await sendC2CInputNotify(token, event.senderId, event.messageId, 60); - log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}`); - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Failed to send input notify: ${err}`); - } - } + // 流式消息开关(默认启用,仅 c2c 支持) + const streamEnabled = account.streamEnabled !== false; + log?.debug?.(`[qqbot:${account.accountId}] Stream enabled: ${streamEnabled}`); pluginRuntime.channel.activity.record({ channel: "qqbot", @@ -269,6 +424,51 @@ export async function startGateway(ctx: GatewayContext): Promise { // 组装消息体,添加系统提示词 let builtinPrompt = ""; + // ============ 用户标识信息(用于定时提醒和主动消息) ============ + const isGroupChat = event.type === "group"; + const targetAddress = isGroupChat ? `group:${event.groupOpenid}` : event.senderId; + + builtinPrompt += ` +【当前用户信息】 +- 用户 openid: ${event.senderId} +- 用户昵称: ${event.senderName || "未知"} +- 消息类型: ${isGroupChat ? "群聊" : "私聊"} +- 当前消息 message_id: ${event.messageId}${isGroupChat ? ` +- 群组 group_openid: ${event.groupOpenid}` : ""} + +【定时提醒能力】 +你可以帮助用户设置定时提醒。使用 openclaw cron 命令: + +示例:5分钟后提醒用户喝水 +\`\`\`bash +openclaw cron add \\ + --name "提醒喝水-${event.senderName || "用户"}" \\ + --at "5m" \\ + --session isolated \\ + --message "💧 该喝水啦!" \\ + --deliver \\ + --channel qqbot \\ + --to "${targetAddress}" \\ + --reply-to "${event.messageId}" \\ + --delete-after-run +\`\`\` + +关键参数说明: +- \`--to\`: 目标地址(当前用户: ${targetAddress}) +- \`--reply-to\`: 回复消息ID(当前消息: ${event.messageId},使提醒能引用原消息) +- \`--at\`: 一次性定时任务的触发时间 + - 相对时间格式:数字+单位,如 \`5m\`(5分钟)、\`1h\`(1小时)、\`2d\`(2天)【注意:不要加 + 号】 + - 绝对时间格式:ISO 8601 带时区,如 \`2026-02-01T14:00:00+08:00\` +- \`--cron\`: 周期性任务(如 \`0 8 * * *\` 每天早上8点) +- \`--tz "Asia/Shanghai"\`: 周期任务务必设置时区 +- \`--delete-after-run\`: 一次性任务必须添加此参数 +- \`--message\`: 消息内容(必填,不能为空!对应 QQ API 的 markdown.content 字段) + +⚠️ 重要注意事项: +1. --at 参数格式:相对时间用 \`5m\`、\`1h\` 等(不要加 + 号!);绝对时间用完整 ISO 格式 +2. 定时提醒消息不支持流式发送,命令中不要添加 --stream 参数 +3. --message 参数必须有实际内容,不能为空字符串`; + // 只有配置了图床公网地址,才告诉 AI 可以发送图片 if (imageServerBaseUrl) { builtinPrompt += ` @@ -400,7 +600,7 @@ export async function startGateway(ctx: GatewayContext): Promise { // 追踪是否有响应 let hasResponse = false; - const responseTimeout = 30000; // 30秒超时 + const responseTimeout = 60000; // 60秒超时(1分钟) let timeoutId: ReturnType | null = null; const timeoutPromise = new Promise((_, reject) => { @@ -417,19 +617,25 @@ export async function startGateway(ctx: GatewayContext): Promise { : event.type === "group" ? `group:${event.groupOpenid}` : `channel:${event.channelId}`; - // 判断是否支持流式(仅 c2c 支持,群聊不支持流式) - const supportsStream = event.type === "c2c"; + // 判断是否支持流式(仅 c2c 支持,群聊不支持流式,且需要开关启用) + const supportsStream = event.type === "c2c" && streamEnabled; + log?.info(`[qqbot:${account.accountId}] Stream support: ${supportsStream} (type=${event.type}, enabled=${streamEnabled})`); // 创建流式发送器 const streamSender = supportsStream ? createStreamSender(account, targetTo, event.messageId) : null; let streamBuffer = ""; // 累积的全部文本(用于记录完整内容) let lastSentLength = 0; // 上次发送时的文本长度(用于计算增量) + let lastSentText = ""; // 上次发送时的完整文本(用于检测新段落) + let currentSegmentStart = 0; // 当前段落在 streamBuffer 中的起始位置 let lastStreamSendTime = 0; // 上次流式发送时间 let streamStarted = false; // 是否已开始流式发送 let streamEnded = false; // 流式是否已结束 + let streamStartTime = 0; // 流式消息开始时间(用于超时检查) let sendingLock = false; // 发送锁,防止并发发送 let pendingFullText = ""; // 待发送的完整文本(在锁定期间积累) let keepaliveTimer: ReturnType | null = null; // 心跳定时器 + let keepaliveCountSinceLastChunk = 0; // 自上次分片以来发送的状态保持消息数量 + let lastChunkSendTime = 0; // 上次分片发送时间(用于判断是否需要发送状态保持) // 清理心跳定时器 const clearKeepalive = () => { @@ -440,26 +646,78 @@ export async function startGateway(ctx: GatewayContext): Promise { }; // 重置心跳定时器(每次发送后调用) - const resetKeepalive = () => { + // isContentChunk: 是否为内容分片(非状态保持消息) + const resetKeepalive = (isContentChunk: boolean = false) => { clearKeepalive(); + + // 如果是内容分片,重置状态保持计数器和时间 + if (isContentChunk) { + keepaliveCountSinceLastChunk = 0; + lastChunkSendTime = Date.now(); + } + if (streamSender && streamStarted && !streamEnded) { + // 计算下次状态保持消息的延迟时间 + // - 首次:3s(STREAM_KEEPALIVE_FIRST_DELAY) + // - 后续:10s(STREAM_KEEPALIVE_GAP) + const delay = keepaliveCountSinceLastChunk === 0 + ? STREAM_KEEPALIVE_FIRST_DELAY + : STREAM_KEEPALIVE_GAP; + keepaliveTimer = setTimeout(async () => { - // 10 秒内没有新消息,发送空分片保持连接 + // 检查流式消息是否超时(超过 3 分钟自动结束) + const elapsed = Date.now() - streamStartTime; + if (elapsed >= STREAM_MAX_DURATION) { + log?.info(`[qqbot:${account.accountId}] Stream timeout after ${Math.round(elapsed / 1000)}s, auto ending stream`); + if (!streamEnded && !sendingLock) { + sendingLock = true; + try { + // 发送结束标记 + await streamSender!.send("", true); + streamEnded = true; + clearKeepalive(); + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Stream auto-end failed: ${err}`); + } finally { + sendingLock = false; + } + } + return; // 超时后不再继续心跳 + } + + // 检查是否已达到每2个分片之间的最大状态保持消息数量 + if (keepaliveCountSinceLastChunk >= STREAM_KEEPALIVE_MAX_PER_CHUNK) { + log?.debug?.(`[qqbot:${account.accountId}] Max keepalive reached (${keepaliveCountSinceLastChunk}/${STREAM_KEEPALIVE_MAX_PER_CHUNK}), waiting for next content chunk`); + // 不再发送状态保持,但继续监控超时 + resetKeepalive(false); + return; + } + + // 检查距上次分片是否超过 3s + const timeSinceLastChunk = Date.now() - lastChunkSendTime; + if (timeSinceLastChunk < STREAM_KEEPALIVE_FIRST_DELAY) { + // 还未到发送状态保持的时机,继续等待 + resetKeepalive(false); + return; + } + + // 发送状态保持消息 if (!streamEnded && !sendingLock) { - log?.info(`[qqbot:${account.accountId}] Sending keepalive empty chunk`); + log?.info(`[qqbot:${account.accountId}] Sending keepalive #${keepaliveCountSinceLastChunk + 1} (elapsed: ${Math.round(elapsed / 1000)}s, since chunk: ${Math.round(timeSinceLastChunk / 1000)}s)`); sendingLock = true; try { // 发送空内容 await streamSender!.send("", false); lastStreamSendTime = Date.now(); - resetKeepalive(); // 继续下一个心跳 + keepaliveCountSinceLastChunk++; + resetKeepalive(false); // 继续下一个状态保持(非内容分片) } catch (err) { log?.error(`[qqbot:${account.accountId}] Keepalive failed: ${err}`); } finally { sendingLock = false; } } - }, STREAM_KEEPALIVE_INTERVAL); + }, delay); } }; @@ -486,8 +744,9 @@ export async function startGateway(ctx: GatewayContext): Promise { streamEnded = true; clearKeepalive(); } else { - // 发送成功后重置心跳 - resetKeepalive(); + // 发送成功后重置心跳,如果是有内容的分片则重置计数器 + const isContentChunk = text.length > 0; + resetKeepalive(isContentChunk); } return true; }; @@ -505,11 +764,17 @@ export async function startGateway(ctx: GatewayContext): Promise { // 发送当前增量 if (fullText.length > lastSentLength) { const increment = fullText.slice(lastSentLength); + // 首次发送前,先设置流式状态和开始时间 + if (!streamStarted) { + streamStarted = true; + streamStartTime = Date.now(); + log?.info(`[qqbot:${account.accountId}] Stream started, max duration: ${STREAM_MAX_DURATION / 1000}s`); + } const success = await sendStreamChunk(increment, forceEnd); if (success) { lastSentLength = fullText.length; + lastSentText = fullText; // 记录完整发送文本,用于检测新段落 lastStreamSendTime = Date.now(); - streamStarted = true; log?.info(`[qqbot:${account.accountId}] Stream partial #${streamSender!.getContext().index}, increment: ${increment.length} chars, total: ${fullText.length} chars`); } } else if (forceEnd && !streamEnded) { @@ -530,6 +795,8 @@ export async function startGateway(ctx: GatewayContext): Promise { }; // onPartialReply 回调 - 实时接收 AI 生成的文本(payload.text 是累积的全文) + // 注意:agent 在一次对话中可能产生多个回复段落(如思考、工具调用后继续回复) + // 每个新段落的 text 会从头开始累积,需要检测并处理 const handlePartialReply = async (payload: { text?: string }) => { if (!streamSender || streamEnded) { log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply skipped: streamSender=${!!streamSender}, streamEnded=${streamEnded}`); @@ -542,11 +809,39 @@ export async function startGateway(ctx: GatewayContext): Promise { return; } - // 始终更新累积缓冲区(即使不发送,也要记录最新内容) - streamBuffer = fullText; hasResponse = true; - log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: fullText.length=${fullText.length}, lastSentLength=${lastSentLength}`); + // 检测是否是新段落: + // 1. lastSentText 不为空(说明已经发送过内容) + // 2. 当前文本不是以 lastSentText 开头(说明不是同一段落的增量) + // 3. 当前文本长度小于 lastSentLength(说明文本被重置了) + const isNewSegment = lastSentText.length > 0 && + (fullText.length < lastSentLength || !fullText.startsWith(lastSentText.slice(0, Math.min(10, lastSentText.length)))); + + if (isNewSegment) { + // 新段落开始,将之前的内容追加到 streamBuffer,并重置发送位置 + log?.info(`[qqbot:${account.accountId}] New segment detected! lastSentLength=${lastSentLength}, newTextLength=${fullText.length}, lastSentText="${lastSentText.slice(0, 20)}...", newText="${fullText.slice(0, 20)}..."`); + + // 记录当前段落在 streamBuffer 中的起始位置 + currentSegmentStart = streamBuffer.length; + + // 追加换行分隔符(如果前面有内容且不以换行结尾) + if (streamBuffer.length > 0 && !streamBuffer.endsWith("\n")) { + streamBuffer += "\n\n"; + currentSegmentStart = streamBuffer.length; + } + + // 重置发送位置,从新段落开始发送 + lastSentLength = 0; + lastSentText = ""; + } + + // 更新当前段落内容到 streamBuffer + // streamBuffer = 之前的段落内容 + 当前段落的完整内容 + const beforeCurrentSegment = streamBuffer.slice(0, currentSegmentStart); + streamBuffer = beforeCurrentSegment + fullText; + + log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: fullText.length=${fullText.length}, lastSentLength=${lastSentLength}, streamBuffer.length=${streamBuffer.length}, isNewSegment=${isNewSegment}`); // 如果没有新内容,跳过 if (fullText.length <= lastSentLength) return; @@ -578,9 +873,15 @@ export async function startGateway(ctx: GatewayContext): Promise { let replyText = payload.text ?? ""; - // 更新 streamBuffer,确保最终内容不会丢失 - if (replyText.length > streamBuffer.length) { - streamBuffer = replyText; + // 更新当前段落内容到 streamBuffer + // deliver 中的 replyText 是当前段落的完整文本 + if (replyText.length > 0) { + const beforeCurrentSegment = streamBuffer.slice(0, currentSegmentStart); + const newStreamBuffer = beforeCurrentSegment + replyText; + if (newStreamBuffer.length > streamBuffer.length) { + streamBuffer = newStreamBuffer; + log?.debug?.(`[qqbot:${account.accountId}] deliver: updated streamBuffer, replyText=${replyText.length}, total=${streamBuffer.length}`); + } } // 收集所有图片路径 @@ -796,18 +1097,18 @@ export async function startGateway(ctx: GatewayContext): Promise { } // 确保所有待发送内容都发送出去 - // 优先使用 pendingFullText,因为它可能包含最新的完整文本 - const finalFullText = pendingFullText && pendingFullText.length > streamBuffer.length + // 当前段落的最新完整文本 + const currentSegmentText = pendingFullText && pendingFullText.length > (streamBuffer.length - currentSegmentStart) ? pendingFullText - : streamBuffer; + : streamBuffer.slice(currentSegmentStart); - // 计算剩余未发送的增量内容 - const remainingIncrement = finalFullText.slice(lastSentLength); + // 计算当前段落剩余未发送的增量内容 + const remainingIncrement = currentSegmentText.slice(lastSentLength); if (remainingIncrement || streamStarted) { // 有剩余内容或者已开始流式,都需要发送结束标记 await streamSender.end(remainingIncrement); streamEnded = true; - log?.info(`[qqbot:${account.accountId}] Stream completed, final increment: ${remainingIncrement.length} chars, total: ${finalFullText.length} chars, chunks: ${streamSender.getContext().index}`); + log?.info(`[qqbot:${account.accountId}] Stream completed, final increment: ${remainingIncrement.length} chars, total streamBuffer: ${streamBuffer.length} chars, chunks: ${streamSender.getContext().index}`); } } } catch (err) { @@ -832,6 +1133,12 @@ export async function startGateway(ctx: GatewayContext): Promise { isConnecting = false; // 连接完成,释放锁 reconnectAttempts = 0; // 连接成功,重置重试计数 lastConnectTime = Date.now(); // 记录连接时间 + // 启动消息处理器(异步处理,防止阻塞心跳) + startMessageProcessor(handleMessage); + // P1-1: 启动后台 Token 刷新 + startBackgroundTokenRefresh(account.appId, account.clientSecret, { + log: log as { info: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void }, + }); }); ws.on("message", async (data) => { @@ -840,7 +1147,20 @@ export async function startGateway(ctx: GatewayContext): Promise { const payload = JSON.parse(rawData) as WSPayload; const { op, d, s, t } = payload; - if (s) lastSeq = s; + if (s) { + lastSeq = s; + // P1-2: 更新持久化存储中的 lastSeq(节流保存) + if (sessionId) { + saveSession({ + sessionId, + lastSeq, + lastConnectedAt: lastConnectTime, + intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex, + accountId: account.accountId, + savedAt: Date.now(), + }); + } + } log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`); @@ -894,12 +1214,39 @@ export async function startGateway(ctx: GatewayContext): Promise { lastSuccessfulIntentLevel = intentLevelIndex; const successLevel = INTENT_LEVELS[intentLevelIndex]; log?.info(`[qqbot:${account.accountId}] Ready with ${successLevel.description}, session: ${sessionId}`); + // P1-2: 保存新的 Session 状态 + saveSession({ + sessionId, + lastSeq, + lastConnectedAt: Date.now(), + intentLevelIndex, + accountId: account.accountId, + savedAt: Date.now(), + }); onReady?.(d); } else if (t === "RESUMED") { log?.info(`[qqbot:${account.accountId}] Session resumed`); + // P1-2: 更新 Session 连接时间 + if (sessionId) { + saveSession({ + sessionId, + lastSeq, + lastConnectedAt: Date.now(), + intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex, + accountId: account.accountId, + savedAt: Date.now(), + }); + } } else if (t === "C2C_MESSAGE_CREATE") { const event = d as C2CMessageEvent; - await handleMessage({ + // P1-3: 记录已知用户 + recordKnownUser({ + openid: event.author.user_openid, + type: "c2c", + accountId: account.accountId, + }); + // 使用消息队列异步处理,防止阻塞心跳 + enqueueMessage({ type: "c2c", senderId: event.author.user_openid, content: event.content, @@ -909,7 +1256,14 @@ export async function startGateway(ctx: GatewayContext): Promise { }); } else if (t === "AT_MESSAGE_CREATE") { const event = d as GuildMessageEvent; - await handleMessage({ + // P1-3: 记录已知用户(频道用户) + recordKnownUser({ + openid: event.author.id, + type: "c2c", // 频道用户按 c2c 类型存储 + nickname: event.author.username, + accountId: account.accountId, + }); + enqueueMessage({ type: "guild", senderId: event.author.id, senderName: event.author.username, @@ -922,7 +1276,14 @@ export async function startGateway(ctx: GatewayContext): Promise { }); } else if (t === "DIRECT_MESSAGE_CREATE") { const event = d as GuildMessageEvent; - await handleMessage({ + // P1-3: 记录已知用户(频道私信用户) + recordKnownUser({ + openid: event.author.id, + type: "c2c", + nickname: event.author.username, + accountId: account.accountId, + }); + enqueueMessage({ type: "dm", senderId: event.author.id, senderName: event.author.username, @@ -934,7 +1295,14 @@ export async function startGateway(ctx: GatewayContext): Promise { }); } else if (t === "GROUP_AT_MESSAGE_CREATE") { const event = d as GroupMessageEvent; - await handleMessage({ + // P1-3: 记录已知用户(群组用户) + recordKnownUser({ + openid: event.author.member_openid, + type: "group", + groupOpenid: event.group_openid, + accountId: account.accountId, + }); + enqueueMessage({ type: "group", senderId: event.author.member_openid, content: event.content, @@ -964,6 +1332,8 @@ export async function startGateway(ctx: GatewayContext): Promise { if (!canResume) { sessionId = null; lastSeq = null; + // P1-2: 清除持久化的 Session + clearSession(account.accountId); // 尝试降级到下一个权限级别 if (intentLevelIndex < INTENT_LEVELS.length - 1) { diff --git a/src/known-users.ts b/src/known-users.ts new file mode 100644 index 0000000..19a5650 --- /dev/null +++ b/src/known-users.ts @@ -0,0 +1,358 @@ +/** + * 已知用户存储 + * 记录与机器人交互过的所有用户 + * 支持主动消息和批量通知功能 + */ + +import fs from "node:fs"; +import path from "node:path"; + +// 已知用户信息接口 +export interface KnownUser { + /** 用户 openid(唯一标识) */ + openid: string; + /** 消息类型:私聊用户 / 群组 */ + type: "c2c" | "group"; + /** 用户昵称(如有) */ + nickname?: string; + /** 群组 openid(如果是群消息) */ + groupOpenid?: string; + /** 关联的机器人账户 ID */ + accountId: string; + /** 首次交互时间戳 */ + firstSeenAt: number; + /** 最后交互时间戳 */ + lastSeenAt: number; + /** 交互次数 */ + interactionCount: number; +} + +// 存储文件路径 +const KNOWN_USERS_DIR = path.join( + process.env.HOME || "/tmp", + "clawd", + "qqbot-data" +); + +const KNOWN_USERS_FILE = path.join(KNOWN_USERS_DIR, "known-users.json"); + +// 内存缓存 +let usersCache: Map | null = null; + +// 写入节流配置 +const SAVE_THROTTLE_MS = 5000; // 5秒写入一次 +let saveTimer: ReturnType | null = null; +let isDirty = false; + +/** + * 确保目录存在 + */ +function ensureDir(): void { + if (!fs.existsSync(KNOWN_USERS_DIR)) { + fs.mkdirSync(KNOWN_USERS_DIR, { recursive: true }); + } +} + +/** + * 从文件加载用户数据到缓存 + */ +function loadUsersFromFile(): Map { + if (usersCache !== null) { + return usersCache; + } + + usersCache = new Map(); + + try { + if (fs.existsSync(KNOWN_USERS_FILE)) { + const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8"); + const users = JSON.parse(data) as KnownUser[]; + + for (const user of users) { + // 使用复合键:accountId + type + openid(群组还要加 groupOpenid) + const key = makeUserKey(user); + usersCache.set(key, user); + } + + console.log(`[known-users] Loaded ${usersCache.size} users from file`); + } + } catch (err) { + console.error(`[known-users] Failed to load users: ${err}`); + usersCache = new Map(); + } + + return usersCache; +} + +/** + * 保存用户数据到文件(节流版本) + */ +function saveUsersToFile(): void { + if (!isDirty) return; + + if (saveTimer) { + return; // 已有定时器在等待 + } + + saveTimer = setTimeout(() => { + saveTimer = null; + doSaveUsersToFile(); + }, SAVE_THROTTLE_MS); +} + +/** + * 实际执行保存 + */ +function doSaveUsersToFile(): void { + if (!usersCache || !isDirty) return; + + try { + ensureDir(); + const users = Array.from(usersCache.values()); + fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(users, null, 2), "utf-8"); + isDirty = false; + console.log(`[known-users] Saved ${users.length} users to file`); + } catch (err) { + console.error(`[known-users] Failed to save users: ${err}`); + } +} + +/** + * 强制立即保存(用于进程退出前) + */ +export function flushKnownUsers(): void { + if (saveTimer) { + clearTimeout(saveTimer); + saveTimer = null; + } + doSaveUsersToFile(); +} + +/** + * 生成用户唯一键 + */ +function makeUserKey(user: Partial): string { + const base = `${user.accountId}:${user.type}:${user.openid}`; + if (user.type === "group" && user.groupOpenid) { + return `${base}:${user.groupOpenid}`; + } + return base; +} + +/** + * 记录已知用户(收到消息时调用) + * @param user 用户信息(部分字段) + */ +export function recordKnownUser(user: { + openid: string; + type: "c2c" | "group"; + nickname?: string; + groupOpenid?: string; + accountId: string; +}): void { + const cache = loadUsersFromFile(); + const key = makeUserKey(user); + const now = Date.now(); + + const existing = cache.get(key); + + if (existing) { + // 更新已存在的用户 + existing.lastSeenAt = now; + existing.interactionCount++; + if (user.nickname && user.nickname !== existing.nickname) { + existing.nickname = user.nickname; + } + console.log(`[known-users] Updated user ${user.openid}, interactions: ${existing.interactionCount}`); + } else { + // 新用户 + const newUser: KnownUser = { + openid: user.openid, + type: user.type, + nickname: user.nickname, + groupOpenid: user.groupOpenid, + accountId: user.accountId, + firstSeenAt: now, + lastSeenAt: now, + interactionCount: 1, + }; + cache.set(key, newUser); + console.log(`[known-users] New user recorded: ${user.openid} (${user.type})`); + } + + isDirty = true; + saveUsersToFile(); +} + +/** + * 获取单个用户信息 + * @param accountId 机器人账户 ID + * @param openid 用户 openid + * @param type 消息类型 + * @param groupOpenid 群组 openid(可选) + */ +export function getKnownUser( + accountId: string, + openid: string, + type: "c2c" | "group" = "c2c", + groupOpenid?: string +): KnownUser | undefined { + const cache = loadUsersFromFile(); + const key = makeUserKey({ accountId, openid, type, groupOpenid }); + return cache.get(key); +} + +/** + * 列出所有已知用户 + * @param options 筛选选项 + */ +export function listKnownUsers(options?: { + /** 筛选特定机器人账户的用户 */ + accountId?: string; + /** 筛选消息类型 */ + type?: "c2c" | "group"; + /** 最近活跃时间(毫秒,如 86400000 表示最近 24 小时) */ + activeWithin?: number; + /** 返回数量限制 */ + limit?: number; + /** 排序方式 */ + sortBy?: "lastSeenAt" | "firstSeenAt" | "interactionCount"; + /** 排序方向 */ + sortOrder?: "asc" | "desc"; +}): KnownUser[] { + const cache = loadUsersFromFile(); + let users = Array.from(cache.values()); + + // 筛选 + if (options?.accountId) { + users = users.filter(u => u.accountId === options.accountId); + } + if (options?.type) { + users = users.filter(u => u.type === options.type); + } + if (options?.activeWithin) { + const cutoff = Date.now() - options.activeWithin; + users = users.filter(u => u.lastSeenAt >= cutoff); + } + + // 排序 + const sortBy = options?.sortBy ?? "lastSeenAt"; + const sortOrder = options?.sortOrder ?? "desc"; + users.sort((a, b) => { + const aVal = a[sortBy] ?? 0; + const bVal = b[sortBy] ?? 0; + return sortOrder === "asc" ? aVal - bVal : bVal - aVal; + }); + + // 限制数量 + if (options?.limit && options.limit > 0) { + users = users.slice(0, options.limit); + } + + return users; +} + +/** + * 获取用户统计信息 + * @param accountId 机器人账户 ID(可选,不传则返回所有账户的统计) + */ +export function getKnownUsersStats(accountId?: string): { + totalUsers: number; + c2cUsers: number; + groupUsers: number; + activeIn24h: number; + activeIn7d: number; +} { + let users = listKnownUsers({ accountId }); + + const now = Date.now(); + const day = 24 * 60 * 60 * 1000; + + return { + totalUsers: users.length, + c2cUsers: users.filter(u => u.type === "c2c").length, + groupUsers: users.filter(u => u.type === "group").length, + activeIn24h: users.filter(u => now - u.lastSeenAt < day).length, + activeIn7d: users.filter(u => now - u.lastSeenAt < 7 * day).length, + }; +} + +/** + * 删除用户记录 + * @param accountId 机器人账户 ID + * @param openid 用户 openid + * @param type 消息类型 + * @param groupOpenid 群组 openid(可选) + */ +export function removeKnownUser( + accountId: string, + openid: string, + type: "c2c" | "group" = "c2c", + groupOpenid?: string +): boolean { + const cache = loadUsersFromFile(); + const key = makeUserKey({ accountId, openid, type, groupOpenid }); + + if (cache.has(key)) { + cache.delete(key); + isDirty = true; + saveUsersToFile(); + console.log(`[known-users] Removed user ${openid}`); + return true; + } + + return false; +} + +/** + * 清除所有用户记录 + * @param accountId 机器人账户 ID(可选,不传则清除所有) + */ +export function clearKnownUsers(accountId?: string): number { + const cache = loadUsersFromFile(); + let count = 0; + + if (accountId) { + // 只清除指定账户的用户 + for (const [key, user] of cache.entries()) { + if (user.accountId === accountId) { + cache.delete(key); + count++; + } + } + } else { + // 清除所有 + count = cache.size; + cache.clear(); + } + + if (count > 0) { + isDirty = true; + doSaveUsersToFile(); // 立即保存 + console.log(`[known-users] Cleared ${count} users`); + } + + return count; +} + +/** + * 获取用户的所有群组(某用户在哪些群里交互过) + * @param accountId 机器人账户 ID + * @param openid 用户 openid + */ +export function getUserGroups(accountId: string, openid: string): string[] { + const users = listKnownUsers({ accountId, type: "group" }); + return users + .filter(u => u.openid === openid && u.groupOpenid) + .map(u => u.groupOpenid!); +} + +/** + * 获取群组的所有成员 + * @param accountId 机器人账户 ID + * @param groupOpenid 群组 openid + */ +export function getGroupMembers(accountId: string, groupOpenid: string): KnownUser[] { + return listKnownUsers({ accountId, type: "group" }) + .filter(u => u.groupOpenid === groupOpenid); +} diff --git a/src/openclaw-plugin-sdk.d.ts b/src/openclaw-plugin-sdk.d.ts new file mode 100644 index 0000000..ef1f1d6 --- /dev/null +++ b/src/openclaw-plugin-sdk.d.ts @@ -0,0 +1,483 @@ +/** + * OpenClaw Plugin SDK 类型声明 + * + * 此文件为 openclaw/plugin-sdk 模块提供 TypeScript 类型声明 + * 仅包含本项目实际使用的类型和函数 + */ + +declare module "openclaw/plugin-sdk" { + // ============ 配置类型 ============ + + /** + * OpenClaw 主配置对象 + */ + export interface OpenClawConfig { + /** 频道配置 */ + channels?: { + qqbot?: unknown; + telegram?: unknown; + discord?: unknown; + slack?: unknown; + whatsapp?: unknown; + [key: string]: unknown; + }; + /** 其他配置字段 */ + [key: string]: unknown; + } + + // ============ 插件运行时 ============ + + /** + * Channel Activity 接口 + */ + export interface ChannelActivity { + record?: (...args: unknown[]) => void; + recordActivity?: (key: string, data?: unknown) => void; + [key: string]: unknown; + } + + /** + * Channel Routing 接口 + */ + export interface ChannelRouting { + resolveAgentRoute?: (...args: unknown[]) => unknown; + resolveSenderAndSession?: (options: unknown) => unknown; + [key: string]: unknown; + } + + /** + * Channel Reply 接口 + */ + export interface ChannelReply { + handleIncomingMessage?: (options: unknown) => Promise; + formatInboundEnvelope?: (...args: unknown[]) => unknown; + finalizeInboundContext?: (...args: unknown[]) => unknown; + resolveEnvelopeFormatOptions?: (...args: unknown[]) => unknown; + handleAutoReply?: (...args: unknown[]) => Promise; + [key: string]: unknown; + } + + /** + * Channel 接口(用于 PluginRuntime) + * 注意:这是一个宽松的类型定义,实际 SDK 中的类型更复杂 + */ + export interface ChannelInterface { + recordInboundSession?: (options: unknown) => void; + handleIncomingMessage?: (options: unknown) => Promise; + activity?: ChannelActivity; + routing?: ChannelRouting; + reply?: ChannelReply; + [key: string]: unknown; + } + + /** + * 插件运行时接口 + * 注意:channel 属性设为 any 是因为 SDK 内部类型非常复杂, + * 且会随 SDK 版本变化。实际使用时 SDK 会提供正确的运行时类型。 + */ + export interface PluginRuntime { + /** 获取当前配置 */ + getConfig(): OpenClawConfig; + /** 更新配置 */ + setConfig(config: OpenClawConfig): void; + /** 获取数据目录路径 */ + getDataDir(): string; + /** Channel 接口 - 使用 any 类型以兼容 SDK 内部复杂类型 */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + channel?: any; + /** 日志函数 */ + log: { + info: (message: string, ...args: unknown[]) => void; + warn: (message: string, ...args: unknown[]) => void; + error: (message: string, ...args: unknown[]) => void; + debug: (message: string, ...args: unknown[]) => void; + }; + /** 其他运行时方法 */ + [key: string]: unknown; + } + + // ============ 插件 API ============ + + /** + * OpenClaw 插件 API + */ + export interface OpenClawPluginApi { + /** 运行时实例 */ + runtime: PluginRuntime; + /** 注册频道 */ + registerChannel(options: { plugin: ChannelPlugin }): void; + /** 其他 API 方法 */ + [key: string]: unknown; + } + + // ============ 插件配置 Schema ============ + + /** + * 空的插件配置 Schema + */ + export function emptyPluginConfigSchema(): unknown; + + // ============ 频道插件 ============ + + /** + * 频道插件 Meta 信息 + */ + export interface ChannelPluginMeta { + id: string; + label: string; + selectionLabel?: string; + docsPath?: string; + blurb?: string; + order?: number; + [key: string]: unknown; + } + + /** + * 频道插件能力配置 + */ + export interface ChannelPluginCapabilities { + chatTypes?: ("direct" | "group" | "channel")[]; + media?: boolean; + reactions?: boolean; + threads?: boolean; + blockStreaming?: boolean; + [key: string]: unknown; + } + + /** + * 账户描述 + */ + export interface AccountDescription { + accountId: string; + name?: string; + enabled: boolean; + configured: boolean; + tokenSource?: string; + [key: string]: unknown; + } + + /** + * 频道插件配置接口(泛型) + */ + export interface ChannelPluginConfig { + listAccountIds: (cfg: OpenClawConfig) => string[]; + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => TAccount; + defaultAccountId: (cfg: OpenClawConfig) => string; + setAccountEnabled?: (ctx: { cfg: OpenClawConfig; accountId: string; enabled: boolean }) => OpenClawConfig; + deleteAccount?: (ctx: { cfg: OpenClawConfig; accountId: string }) => OpenClawConfig; + isConfigured?: (account: TAccount | undefined) => boolean; + describeAccount?: (account: TAccount | undefined) => AccountDescription; + [key: string]: unknown; + } + + /** + * Setup 输入参数(扩展类型以支持 QQBot 特定字段) + */ + export interface SetupInput { + token?: string; + tokenFile?: string; + useEnv?: boolean; + name?: string; + imageServerBaseUrl?: string; + [key: string]: unknown; + } + + /** + * 频道插件 Setup 接口 + */ + export interface ChannelPluginSetup { + resolveAccountId?: (ctx: { accountId?: string }) => string; + applyAccountName?: (ctx: { cfg: OpenClawConfig; accountId: string; name: string }) => OpenClawConfig; + validateInput?: (ctx: { input: SetupInput }) => string | null; + applyConfig?: (ctx: { cfg: OpenClawConfig; accountId: string; input: SetupInput }) => OpenClawConfig; + applyAccountConfig?: (ctx: { cfg: OpenClawConfig; accountId: string; input: SetupInput }) => OpenClawConfig; + [key: string]: unknown; + } + + /** + * 消息目标解析结果 + */ + export interface NormalizeTargetResult { + ok: boolean; + to?: string; + error?: string; + } + + /** + * 目标解析器 + */ + export interface TargetResolver { + looksLikeId?: (id: string) => boolean; + hint?: string; + } + + /** + * 频道插件 Messaging 接口 + */ + export interface ChannelPluginMessaging { + normalizeTarget?: (target: string) => NormalizeTargetResult; + targetResolver?: TargetResolver; + [key: string]: unknown; + } + + /** + * 发送文本结果 + */ + export interface SendTextResult { + channel: string; + messageId?: string; + error?: Error; + } + + /** + * 发送文本上下文 + */ + export interface SendTextContext { + to: string; + text: string; + accountId?: string; + replyToId?: string; + cfg: OpenClawConfig; + } + + /** + * 发送媒体上下文 + */ + export interface SendMediaContext { + to: string; + text?: string; + mediaUrl?: string; + accountId?: string; + replyToId?: string; + cfg: OpenClawConfig; + } + + /** + * 频道插件 Outbound 接口 + */ + export interface ChannelPluginOutbound { + deliveryMode?: "direct" | "queued"; + chunker?: (text: string, limit: number) => string[]; + chunkerMode?: "markdown" | "plain"; + textChunkLimit?: number; + sendText?: (ctx: SendTextContext) => Promise; + sendMedia?: (ctx: SendMediaContext) => Promise; + [key: string]: unknown; + } + + /** + * 账户状态 + */ + export interface AccountStatus { + running?: boolean; + connected?: boolean; + lastConnectedAt?: number; + lastError?: string; + [key: string]: unknown; + } + + /** + * Gateway 启动上下文 + */ + export interface GatewayStartContext { + account: TAccount; + accountId: string; + abortSignal: AbortSignal; + cfg: OpenClawConfig; + log?: { + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; + debug: (msg: string) => void; + }; + getStatus: () => AccountStatus; + setStatus: (status: AccountStatus) => void; + [key: string]: unknown; + } + + /** + * Gateway 登出上下文 + */ + export interface GatewayLogoutContext { + accountId: string; + cfg: OpenClawConfig; + [key: string]: unknown; + } + + /** + * Gateway 登出结果 + */ + export interface GatewayLogoutResult { + ok: boolean; + cleared: boolean; + updatedConfig?: OpenClawConfig; + error?: string; + } + + /** + * 频道插件 Gateway 接口 + */ + export interface ChannelPluginGateway { + startAccount?: (ctx: GatewayStartContext) => Promise; + logoutAccount?: (ctx: GatewayLogoutContext) => Promise; + [key: string]: unknown; + } + + /** + * 频道插件接口(泛型) + */ + export interface ChannelPlugin { + /** 插件 ID */ + id: string; + /** 插件 Meta 信息 */ + meta?: ChannelPluginMeta; + /** 插件版本 */ + version?: string; + /** 插件能力 */ + capabilities?: ChannelPluginCapabilities; + /** 重载配置 */ + reload?: { configPrefixes?: string[] }; + /** Onboarding 适配器 */ + onboarding?: ChannelOnboardingAdapter; + /** 配置方法 */ + config?: ChannelPluginConfig; + /** Setup 方法 */ + setup?: ChannelPluginSetup; + /** Messaging 配置 */ + messaging?: ChannelPluginMessaging; + /** Outbound 配置 */ + outbound?: ChannelPluginOutbound; + /** Gateway 配置 */ + gateway?: ChannelPluginGateway; + /** 启动函数 */ + start?: (runtime: PluginRuntime) => void | Promise; + /** 停止函数 */ + stop?: () => void | Promise; + /** deliver 函数 - 发送消息 */ + deliver?: (ctx: unknown) => Promise; + /** 其他插件属性 */ + [key: string]: unknown; + } + + // ============ Onboarding 类型 ============ + + /** + * Onboarding 状态结果 + */ + export interface ChannelOnboardingStatus { + channel?: string; + configured: boolean; + statusLines?: string[]; + selectionHint?: string; + quickstartScore?: number; + [key: string]: unknown; + } + + /** + * Onboarding 状态字符串枚举(部分 API 使用) + */ + export type ChannelOnboardingStatusString = + | "not-configured" + | "configured" + | "connected" + | "error"; + + /** + * Onboarding 状态上下文 + */ + export interface ChannelOnboardingStatusContext { + /** 当前配置 */ + config: OpenClawConfig; + /** 账户 ID */ + accountId?: string; + /** Prompter */ + prompter?: unknown; + /** 其他上下文 */ + [key: string]: unknown; + } + + /** + * Onboarding 配置上下文 + */ + export interface ChannelOnboardingConfigureContext { + /** 当前配置 */ + config: OpenClawConfig; + /** 账户 ID */ + accountId?: string; + /** 输入参数 */ + input?: Record; + /** Prompter */ + prompter?: unknown; + /** 其他上下文 */ + [key: string]: unknown; + } + + /** + * Onboarding 结果 + */ + export interface ChannelOnboardingResult { + /** 是否成功 */ + success: boolean; + /** 更新后的配置 */ + config?: OpenClawConfig; + /** 错误信息 */ + error?: string; + /** 消息 */ + message?: string; + /** 其他结果字段 */ + [key: string]: unknown; + } + + /** + * Onboarding 适配器接口 + */ + export interface ChannelOnboardingAdapter { + /** 获取状态 */ + getStatus?: (ctx: ChannelOnboardingStatusContext) => ChannelOnboardingStatus | Promise; + /** 配置函数 */ + configure?: (ctx: ChannelOnboardingConfigureContext) => ChannelOnboardingResult | Promise; + /** 其他适配器方法 */ + [key: string]: unknown; + } + + // ============ 配置辅助函数 ============ + + /** + * 将账户名称应用到频道配置段 + */ + export function applyAccountNameToChannelSection(ctx: { + cfg: OpenClawConfig; + channelKey: string; + accountId: string; + name: string; + }): OpenClawConfig; + + /** + * 从配置段删除账户 + */ + export function deleteAccountFromConfigSection(ctx: { + cfg: OpenClawConfig; + sectionKey: string; + accountId: string; + clearBaseFields?: string[]; + }): OpenClawConfig; + + /** + * 设置账户启用状态 + */ + export function setAccountEnabledInConfigSection(ctx: { + cfg: OpenClawConfig; + sectionKey: string; + accountId: string; + enabled: boolean; + allowTopLevel?: boolean; + }): OpenClawConfig; + + // ============ 其他导出 ============ + + /** 默认账户 ID 常量 */ + export const DEFAULT_ACCOUNT_ID: string; + + /** 规范化账户 ID */ + export function normalizeAccountId(accountId: string | undefined | null): string; +} diff --git a/src/outbound.ts b/src/outbound.ts index 1d9510b..2a00889 100644 --- a/src/outbound.ts +++ b/src/outbound.ts @@ -16,6 +16,133 @@ import { type StreamMessageResponse, } from "./api.js"; +// ============ 消息回复限流器 ============ +// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息) +const MESSAGE_REPLY_LIMIT = 4; +const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时 + +interface MessageReplyRecord { + count: number; + firstReplyAt: number; +} + +const messageReplyTracker = new Map(); + +/** 限流检查结果 */ +export interface ReplyLimitResult { + /** 是否允许被动回复 */ + allowed: boolean; + /** 剩余被动回复次数 */ + remaining: number; + /** 是否需要降级为主动消息(超期或超过次数) */ + shouldFallbackToProactive: boolean; + /** 降级原因 */ + fallbackReason?: "expired" | "limit_exceeded"; + /** 提示消息 */ + message?: string; +} + +/** + * 检查是否可以回复该消息(限流检查) + * @param messageId 消息ID + * @returns ReplyLimitResult 限流检查结果 + */ +export function checkMessageReplyLimit(messageId: string): ReplyLimitResult { + const now = Date.now(); + const record = messageReplyTracker.get(messageId); + + // 清理过期记录(定期清理,避免内存泄漏) + if (messageReplyTracker.size > 10000) { + for (const [id, rec] of messageReplyTracker) { + if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) { + messageReplyTracker.delete(id); + } + } + } + + // 新消息,首次回复 + if (!record) { + return { + allowed: true, + remaining: MESSAGE_REPLY_LIMIT, + shouldFallbackToProactive: false, + }; + } + + // 检查是否超过1小时(message_id 过期) + if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) { + // 超过1小时,被动回复不可用,需要降级为主动消息 + return { + allowed: false, + remaining: 0, + shouldFallbackToProactive: true, + fallbackReason: "expired", + message: `消息已超过1小时有效期,将使用主动消息发送`, + }; + } + + // 检查是否超过回复次数限制 + const remaining = MESSAGE_REPLY_LIMIT - record.count; + if (remaining <= 0) { + return { + allowed: false, + remaining: 0, + shouldFallbackToProactive: true, + fallbackReason: "limit_exceeded", + message: `该消息已达到1小时内最大回复次数(${MESSAGE_REPLY_LIMIT}次),将使用主动消息发送`, + }; + } + + return { + allowed: true, + remaining, + shouldFallbackToProactive: false, + }; +} + +/** + * 记录一次消息回复 + * @param messageId 消息ID + */ +export function recordMessageReply(messageId: string): void { + const now = Date.now(); + const record = messageReplyTracker.get(messageId); + + if (!record) { + messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now }); + } else { + // 检查是否过期,过期则重新计数 + if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) { + messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now }); + } else { + record.count++; + } + } + console.log(`[qqbot] recordMessageReply: ${messageId}, count=${messageReplyTracker.get(messageId)?.count}`); +} + +/** + * 获取消息回复统计信息 + */ +export function getMessageReplyStats(): { trackedMessages: number; totalReplies: number } { + let totalReplies = 0; + for (const record of messageReplyTracker.values()) { + totalReplies += record.count; + } + return { trackedMessages: messageReplyTracker.size, totalReplies }; +} + +/** + * 获取消息回复限制配置(供外部查询) + */ +export function getMessageReplyConfig(): { limit: number; ttlMs: number; ttlHours: number } { + return { + limit: MESSAGE_REPLY_LIMIT, + ttlMs: MESSAGE_REPLY_TTL, + ttlHours: MESSAGE_REPLY_TTL / (60 * 60 * 1000), + }; +} + export interface OutboundContext { to: string; text: string; @@ -211,14 +338,61 @@ function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: strin /** * 发送文本消息 - * - 有 replyToId: 被动回复,无配额限制 + * - 有 replyToId: 被动回复,1小时内最多回复4次 * - 无 replyToId: 主动发送,有配额限制(每月4条/用户/群) + * + * 注意: + * 1. 主动消息(无 replyToId)必须有消息内容,不支持流式发送 + * 2. 当被动回复不可用(超期或超过次数)时,自动降级为主动消息 */ export async function sendText(ctx: OutboundContext): Promise { - const { to, text, replyToId, account } = ctx; + const { to, text, account } = ctx; + let { replyToId } = ctx; + let fallbackToProactive = false; console.log("[qqbot] sendText ctx:", JSON.stringify({ to, text: text?.slice(0, 50), replyToId, accountId: account.accountId }, null, 2)); + // ============ 消息回复限流检查 ============ + // 如果有 replyToId,检查是否可以被动回复 + if (replyToId) { + const limitCheck = checkMessageReplyLimit(replyToId); + + if (!limitCheck.allowed) { + // 检查是否需要降级为主动消息 + if (limitCheck.shouldFallbackToProactive) { + console.warn(`[qqbot] sendText: 被动回复不可用,降级为主动消息 - ${limitCheck.message}`); + fallbackToProactive = true; + replyToId = null; // 清除 replyToId,改为主动消息 + } else { + // 不应该发生,但作为保底 + console.error(`[qqbot] sendText: 消息回复被限流但未设置降级 - ${limitCheck.message}`); + return { + channel: "qqbot", + error: limitCheck.message + }; + } + } else { + console.log(`[qqbot] sendText: 消息 ${replyToId} 剩余被动回复次数: ${limitCheck.remaining}/${MESSAGE_REPLY_LIMIT}`); + } + } + + // ============ 主动消息校验(参考 Telegram 机制) ============ + // 如果是主动消息(无 replyToId 或降级后),必须有消息内容 + if (!replyToId) { + if (!text || text.trim().length === 0) { + console.error("[qqbot] sendText error: 主动消息的内容不能为空 (text is empty)"); + return { + channel: "qqbot", + error: "主动消息必须有内容 (--message 参数不能为空)" + }; + } + if (fallbackToProactive) { + console.log(`[qqbot] sendText: [降级] 发送主动消息到 ${to}, 内容长度: ${text.length}`); + } else { + console.log(`[qqbot] sendText: 发送主动消息到 ${to}, 内容长度: ${text.length}`); + } + } + if (!account.appId || !account.clientSecret) { return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" }; } @@ -246,12 +420,18 @@ export async function sendText(ctx: OutboundContext): Promise { // 有 replyToId,使用被动回复接口 if (target.type === "c2c") { const result = await sendC2CMessage(accessToken, target.id, text, replyToId); + // 记录回复次数 + recordMessageReply(replyToId); return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; } else if (target.type === "group") { const result = await sendGroupMessage(accessToken, target.id, text, replyToId); + // 记录回复次数 + recordMessageReply(replyToId); return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; } else { const result = await sendChannelMessage(accessToken, target.id, text, replyToId); + // 记录回复次数 + recordMessageReply(replyToId); return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp }; } } catch (err) { diff --git a/src/proactive.ts b/src/proactive.ts new file mode 100644 index 0000000..2393bf3 --- /dev/null +++ b/src/proactive.ts @@ -0,0 +1,528 @@ +/** + * QQ Bot 主动发送消息模块 + * + * 该模块提供以下能力: + * 1. 记录已知用户(曾与机器人交互过的用户) + * 2. 主动发送消息给用户或群组 + * 3. 查询已知用户列表 + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { ResolvedQQBotAccount } from "./types.js"; + +// ============ 类型定义(本地) ============ + +/** + * 已知用户信息 + */ +export interface KnownUser { + type: "c2c" | "group" | "channel"; + openid: string; + accountId: string; + nickname?: string; + firstInteractionAt: number; + lastInteractionAt: number; +} + +/** + * 主动发送消息选项 + */ +export interface ProactiveSendOptions { + to: string; + text: string; + type?: "c2c" | "group" | "channel"; + imageUrl?: string; + accountId?: string; +} + +/** + * 主动发送消息结果 + */ +export interface ProactiveSendResult { + success: boolean; + messageId?: string; + timestamp?: number | string; + error?: string; +} + +/** + * 列出已知用户选项 + */ +export interface ListKnownUsersOptions { + type?: "c2c" | "group" | "channel"; + accountId?: string; + sortByLastInteraction?: boolean; + limit?: number; +} +import { + getAccessToken, + sendProactiveC2CMessage, + sendProactiveGroupMessage, + sendChannelMessage, + sendC2CImageMessage, + sendGroupImageMessage, +} from "./api.js"; +import { resolveQQBotAccount } from "./config.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk"; + +// ============ 用户存储管理 ============ + +/** + * 已知用户存储 + * 使用简单的 JSON 文件存储,保存在 clawd 目录下 + */ +const STORAGE_DIR = path.join(process.env.HOME || "/home/ubuntu", "clawd", "qqbot-data"); +const KNOWN_USERS_FILE = path.join(STORAGE_DIR, "known-users.json"); + +// 内存缓存 +let knownUsersCache: Map | null = null; +let cacheLastModified = 0; + +/** + * 确保存储目录存在 + */ +function ensureStorageDir(): void { + if (!fs.existsSync(STORAGE_DIR)) { + fs.mkdirSync(STORAGE_DIR, { recursive: true }); + } +} + +/** + * 生成用户唯一键 + */ +function getUserKey(type: string, openid: string, accountId: string): string { + return `${accountId}:${type}:${openid}`; +} + +/** + * 从文件加载已知用户 + */ +function loadKnownUsers(): Map { + if (knownUsersCache !== null) { + // 检查文件是否被修改 + try { + const stat = fs.statSync(KNOWN_USERS_FILE); + if (stat.mtimeMs <= cacheLastModified) { + return knownUsersCache; + } + } catch { + // 文件不存在,使用缓存 + return knownUsersCache; + } + } + + const users = new Map(); + + try { + if (fs.existsSync(KNOWN_USERS_FILE)) { + const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8"); + const parsed = JSON.parse(data) as KnownUser[]; + for (const user of parsed) { + const key = getUserKey(user.type, user.openid, user.accountId); + users.set(key, user); + } + cacheLastModified = fs.statSync(KNOWN_USERS_FILE).mtimeMs; + } + } catch (err) { + console.error(`[qqbot:proactive] Failed to load known users: ${err}`); + } + + knownUsersCache = users; + return users; +} + +/** + * 保存已知用户到文件 + */ +function saveKnownUsers(users: Map): void { + try { + ensureStorageDir(); + const data = Array.from(users.values()); + fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(data, null, 2), "utf-8"); + cacheLastModified = Date.now(); + knownUsersCache = users; + } catch (err) { + console.error(`[qqbot:proactive] Failed to save known users: ${err}`); + } +} + +/** + * 记录一个已知用户(当收到用户消息时调用) + * + * @param user - 用户信息 + */ +export function recordKnownUser(user: Omit): void { + const users = loadKnownUsers(); + const key = getUserKey(user.type, user.openid, user.accountId); + + const existing = users.get(key); + const now = user.lastInteractionAt || Date.now(); + + users.set(key, { + ...user, + lastInteractionAt: now, + firstInteractionAt: existing?.firstInteractionAt ?? now, + // 更新昵称(如果有新的) + nickname: user.nickname || existing?.nickname, + }); + + saveKnownUsers(users); + console.log(`[qqbot:proactive] Recorded user: ${key}`); +} + +/** + * 获取一个已知用户 + * + * @param type - 用户类型 + * @param openid - 用户 openid + * @param accountId - 账户 ID + */ +export function getKnownUser(type: string, openid: string, accountId: string): KnownUser | undefined { + const users = loadKnownUsers(); + const key = getUserKey(type, openid, accountId); + return users.get(key); +} + +/** + * 列出已知用户 + * + * @param options - 过滤选项 + */ +export function listKnownUsers(options?: ListKnownUsersOptions): KnownUser[] { + const users = loadKnownUsers(); + let result = Array.from(users.values()); + + // 过滤类型 + if (options?.type) { + result = result.filter(u => u.type === options.type); + } + + // 过滤账户 + if (options?.accountId) { + result = result.filter(u => u.accountId === options.accountId); + } + + // 排序 + if (options?.sortByLastInteraction !== false) { + result.sort((a, b) => b.lastInteractionAt - a.lastInteractionAt); + } + + // 限制数量 + if (options?.limit && options.limit > 0) { + result = result.slice(0, options.limit); + } + + return result; +} + +/** + * 删除一个已知用户 + * + * @param type - 用户类型 + * @param openid - 用户 openid + * @param accountId - 账户 ID + */ +export function removeKnownUser(type: string, openid: string, accountId: string): boolean { + const users = loadKnownUsers(); + const key = getUserKey(type, openid, accountId); + const deleted = users.delete(key); + if (deleted) { + saveKnownUsers(users); + } + return deleted; +} + +/** + * 清除所有已知用户 + * + * @param accountId - 可选,只清除指定账户的用户 + */ +export function clearKnownUsers(accountId?: string): number { + const users = loadKnownUsers(); + let count = 0; + + if (accountId) { + for (const [key, user] of users) { + if (user.accountId === accountId) { + users.delete(key); + count++; + } + } + } else { + count = users.size; + users.clear(); + } + + if (count > 0) { + saveKnownUsers(users); + } + return count; +} + +// ============ 主动发送消息 ============ + +/** + * 主动发送消息(带配置解析) + * 注意:与 outbound.ts 中的 sendProactiveMessage 不同,这个函数接受 OpenClawConfig 并自动解析账户 + * + * @param options - 发送选项 + * @param cfg - OpenClaw 配置 + * @returns 发送结果 + * + * @example + * ```typescript + * // 发送私聊消息 + * const result = await sendProactive({ + * to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4", // 用户 openid + * text: "你好!这是一条主动消息", + * type: "c2c", + * }, cfg); + * + * // 发送群聊消息 + * const result = await sendProactive({ + * to: "A1B2C3D4E5F6A7B8", // 群组 openid + * text: "群公告:今天有活动", + * type: "group", + * }, cfg); + * + * // 发送带图片的消息 + * const result = await sendProactive({ + * to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4", + * text: "看看这张图片", + * imageUrl: "https://example.com/image.png", + * type: "c2c", + * }, cfg); + * ``` + */ +export async function sendProactive( + options: ProactiveSendOptions, + cfg: OpenClawConfig +): Promise { + const { to, text, type = "c2c", imageUrl, accountId = "default" } = options; + + // 解析账户配置 + const account = resolveQQBotAccount(cfg, accountId); + + if (!account.appId || !account.clientSecret) { + return { + success: false, + error: "QQBot not configured (missing appId or clientSecret)", + }; + } + + try { + const accessToken = await getAccessToken(account.appId, account.clientSecret); + + // 如果有图片,先发送图片 + if (imageUrl) { + try { + if (type === "c2c") { + await sendC2CImageMessage(accessToken, to, imageUrl, undefined, undefined); + } else if (type === "group") { + await sendGroupImageMessage(accessToken, to, imageUrl, undefined, undefined); + } + console.log(`[qqbot:proactive] Sent image to ${type}:${to}`); + } catch (err) { + console.error(`[qqbot:proactive] Failed to send image: ${err}`); + // 图片发送失败不影响文本发送 + } + } + + // 发送文本消息 + let result: { id: string; timestamp: number | string }; + + if (type === "c2c") { + result = await sendProactiveC2CMessage(accessToken, to, text); + } else if (type === "group") { + result = await sendProactiveGroupMessage(accessToken, to, text); + } else if (type === "channel") { + // 频道消息需要 channel_id,这里暂时不支持主动发送 + return { + success: false, + error: "Channel proactive messages are not supported. Please use group or c2c.", + }; + } else { + return { + success: false, + error: `Unknown message type: ${type}`, + }; + } + + console.log(`[qqbot:proactive] Sent message to ${type}:${to}, id: ${result.id}`); + + return { + success: true, + messageId: result.id, + timestamp: result.timestamp, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[qqbot:proactive] Failed to send message: ${message}`); + + return { + success: false, + error: message, + }; + } +} + +/** + * 批量发送主动消息 + * + * @param recipients - 接收者列表(openid 数组) + * @param text - 消息内容 + * @param type - 消息类型 + * @param cfg - OpenClaw 配置 + * @param accountId - 账户 ID + * @returns 发送结果列表 + */ +export async function sendBulkProactiveMessage( + recipients: string[], + text: string, + type: "c2c" | "group", + cfg: OpenClawConfig, + accountId = "default" +): Promise> { + const results: Array<{ to: string; result: ProactiveSendResult }> = []; + + for (const to of recipients) { + const result = await sendProactive({ to, text, type, accountId }, cfg); + results.push({ to, result }); + + // 添加延迟,避免频率限制 + await new Promise(resolve => setTimeout(resolve, 500)); + } + + return results; +} + +/** + * 发送消息给所有已知用户 + * + * @param text - 消息内容 + * @param cfg - OpenClaw 配置 + * @param options - 过滤选项 + * @returns 发送结果统计 + */ +export async function broadcastMessage( + text: string, + cfg: OpenClawConfig, + options?: { + type?: "c2c" | "group"; + accountId?: string; + limit?: number; + } +): Promise<{ + total: number; + success: number; + failed: number; + results: Array<{ to: string; result: ProactiveSendResult }>; +}> { + const users = listKnownUsers({ + type: options?.type, + accountId: options?.accountId, + limit: options?.limit, + sortByLastInteraction: true, + }); + + // 过滤掉频道用户(不支持主动发送) + const validUsers = users.filter(u => u.type === "c2c" || u.type === "group"); + + const results: Array<{ to: string; result: ProactiveSendResult }> = []; + let success = 0; + let failed = 0; + + for (const user of validUsers) { + const result = await sendProactive({ + to: user.openid, + text, + type: user.type as "c2c" | "group", + accountId: user.accountId, + }, cfg); + + results.push({ to: user.openid, result }); + + if (result.success) { + success++; + } else { + failed++; + } + + // 添加延迟,避免频率限制 + await new Promise(resolve => setTimeout(resolve, 500)); + } + + return { + total: validUsers.length, + success, + failed, + results, + }; +} + +// ============ 辅助函数 ============ + +/** + * 根据账户配置直接发送主动消息(不需要 cfg) + * + * @param account - 已解析的账户配置 + * @param to - 目标 openid + * @param text - 消息内容 + * @param type - 消息类型 + */ +export async function sendProactiveMessageDirect( + account: ResolvedQQBotAccount, + to: string, + text: string, + type: "c2c" | "group" = "c2c" +): Promise { + if (!account.appId || !account.clientSecret) { + return { + success: false, + error: "QQBot not configured (missing appId or clientSecret)", + }; + } + + try { + const accessToken = await getAccessToken(account.appId, account.clientSecret); + + let result: { id: string; timestamp: number | string }; + + if (type === "c2c") { + result = await sendProactiveC2CMessage(accessToken, to, text); + } else { + result = await sendProactiveGroupMessage(accessToken, to, text); + } + + return { + success: true, + messageId: result.id, + timestamp: result.timestamp, + }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +/** + * 获取已知用户统计 + */ +export function getKnownUsersStats(accountId?: string): { + total: number; + c2c: number; + group: number; + channel: number; +} { + const users = listKnownUsers({ accountId }); + + return { + total: users.length, + c2c: users.filter(u => u.type === "c2c").length, + group: users.filter(u => u.type === "group").length, + channel: users.filter(u => u.type === "channel").length, + }; +} diff --git a/src/session-store.ts b/src/session-store.ts new file mode 100644 index 0000000..20491d0 --- /dev/null +++ b/src/session-store.ts @@ -0,0 +1,292 @@ +/** + * Session 持久化存储 + * 将 WebSocket 连接状态(sessionId、lastSeq)持久化到文件 + * 支持进程重启后通过 Resume 机制快速恢复连接 + */ + +import fs from "node:fs"; +import path from "node:path"; + +// Session 状态接口 +export interface SessionState { + /** WebSocket Session ID */ + sessionId: string | null; + /** 最后收到的消息序号 */ + lastSeq: number | null; + /** 上次连接成功的时间戳 */ + lastConnectedAt: number; + /** 上次成功的权限级别索引 */ + intentLevelIndex: number; + /** 关联的机器人账户 ID */ + accountId: string; + /** 保存时间 */ + savedAt: number; +} + +// Session 文件目录 +const SESSION_DIR = path.join( + process.env.HOME || "/tmp", + "clawd", + "qqbot-data" +); + +// Session 过期时间(5分钟)- Resume 要求在断开后一定时间内恢复 +const SESSION_EXPIRE_TIME = 5 * 60 * 1000; + +// 写入节流时间(避免频繁写入) +const SAVE_THROTTLE_MS = 1000; + +// 每个账户的节流状态 +const throttleState = new Map | null; +}>(); + +/** + * 确保目录存在 + */ +function ensureDir(): void { + if (!fs.existsSync(SESSION_DIR)) { + fs.mkdirSync(SESSION_DIR, { recursive: true }); + } +} + +/** + * 获取 Session 文件路径 + */ +function getSessionPath(accountId: string): string { + // 清理 accountId 中的特殊字符 + const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, "_"); + return path.join(SESSION_DIR, `session-${safeId}.json`); +} + +/** + * 加载 Session 状态 + * @param accountId 账户 ID + * @returns Session 状态,如果不存在或已过期返回 null + */ +export function loadSession(accountId: string): SessionState | null { + const filePath = getSessionPath(accountId); + + try { + if (!fs.existsSync(filePath)) { + return null; + } + + const data = fs.readFileSync(filePath, "utf-8"); + const state = JSON.parse(data) as SessionState; + + // 检查是否过期 + const now = Date.now(); + if (now - state.savedAt > SESSION_EXPIRE_TIME) { + console.log(`[session-store] Session expired for ${accountId}, age: ${Math.round((now - state.savedAt) / 1000)}s`); + // 删除过期文件 + try { + fs.unlinkSync(filePath); + } catch { + // 忽略删除错误 + } + return null; + } + + // 验证必要字段 + if (!state.sessionId || state.lastSeq === null || state.lastSeq === undefined) { + console.log(`[session-store] Invalid session data for ${accountId}`); + return null; + } + + console.log(`[session-store] Loaded session for ${accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}, age=${Math.round((now - state.savedAt) / 1000)}s`); + return state; + } catch (err) { + console.error(`[session-store] Failed to load session for ${accountId}: ${err}`); + return null; + } +} + +/** + * 保存 Session 状态(带节流,避免频繁写入) + * @param state Session 状态 + */ +export function saveSession(state: SessionState): void { + const { accountId } = state; + + // 获取或初始化节流状态 + let throttle = throttleState.get(accountId); + if (!throttle) { + throttle = { + pendingState: null, + lastSaveTime: 0, + throttleTimer: null, + }; + throttleState.set(accountId, throttle); + } + + const now = Date.now(); + const timeSinceLastSave = now - throttle.lastSaveTime; + + // 如果距离上次保存时间足够长,立即保存 + if (timeSinceLastSave >= SAVE_THROTTLE_MS) { + doSaveSession(state); + throttle.lastSaveTime = now; + throttle.pendingState = null; + + // 清除待定的节流定时器 + if (throttle.throttleTimer) { + clearTimeout(throttle.throttleTimer); + throttle.throttleTimer = null; + } + } else { + // 记录待保存的状态 + throttle.pendingState = state; + + // 如果没有设置定时器,设置一个 + if (!throttle.throttleTimer) { + const delay = SAVE_THROTTLE_MS - timeSinceLastSave; + throttle.throttleTimer = setTimeout(() => { + const t = throttleState.get(accountId); + if (t && t.pendingState) { + doSaveSession(t.pendingState); + t.lastSaveTime = Date.now(); + t.pendingState = null; + } + if (t) { + t.throttleTimer = null; + } + }, delay); + } + } +} + +/** + * 实际执行保存操作 + */ +function doSaveSession(state: SessionState): void { + const filePath = getSessionPath(state.accountId); + + try { + ensureDir(); + + // 更新保存时间 + const stateToSave: SessionState = { + ...state, + savedAt: Date.now(), + }; + + fs.writeFileSync(filePath, JSON.stringify(stateToSave, null, 2), "utf-8"); + console.log(`[session-store] Saved session for ${state.accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}`); + } catch (err) { + console.error(`[session-store] Failed to save session for ${state.accountId}: ${err}`); + } +} + +/** + * 清除 Session 状态 + * @param accountId 账户 ID + */ +export function clearSession(accountId: string): void { + const filePath = getSessionPath(accountId); + + // 清除节流状态 + const throttle = throttleState.get(accountId); + if (throttle) { + if (throttle.throttleTimer) { + clearTimeout(throttle.throttleTimer); + } + throttleState.delete(accountId); + } + + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + console.log(`[session-store] Cleared session for ${accountId}`); + } + } catch (err) { + console.error(`[session-store] Failed to clear session for ${accountId}: ${err}`); + } +} + +/** + * 更新 lastSeq(轻量级更新) + * @param accountId 账户 ID + * @param lastSeq 最新的消息序号 + */ +export function updateLastSeq(accountId: string, lastSeq: number): void { + const existing = loadSession(accountId); + if (existing && existing.sessionId) { + saveSession({ + ...existing, + lastSeq, + }); + } +} + +/** + * 获取所有保存的 Session 状态 + */ +export function getAllSessions(): SessionState[] { + const sessions: SessionState[] = []; + + try { + ensureDir(); + const files = fs.readdirSync(SESSION_DIR); + + for (const file of files) { + if (file.startsWith("session-") && file.endsWith(".json")) { + const filePath = path.join(SESSION_DIR, file); + try { + const data = fs.readFileSync(filePath, "utf-8"); + const state = JSON.parse(data) as SessionState; + sessions.push(state); + } catch { + // 忽略解析错误 + } + } + } + } catch { + // 目录不存在等错误 + } + + return sessions; +} + +/** + * 清理过期的 Session 文件 + */ +export function cleanupExpiredSessions(): number { + let cleaned = 0; + + try { + ensureDir(); + const files = fs.readdirSync(SESSION_DIR); + const now = Date.now(); + + for (const file of files) { + if (file.startsWith("session-") && file.endsWith(".json")) { + const filePath = path.join(SESSION_DIR, file); + try { + const data = fs.readFileSync(filePath, "utf-8"); + const state = JSON.parse(data) as SessionState; + + if (now - state.savedAt > SESSION_EXPIRE_TIME) { + fs.unlinkSync(filePath); + cleaned++; + console.log(`[session-store] Cleaned expired session: ${file}`); + } + } catch { + // 忽略解析错误,但也删除损坏的文件 + try { + fs.unlinkSync(filePath); + cleaned++; + } catch { + // 忽略 + } + } + } + } + } catch { + // 目录不存在等错误 + } + + return cleaned; +} diff --git a/src/types.ts b/src/types.ts index b86eae9..0da4944 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,8 @@ export interface ResolvedQQBotAccount { imageServerBaseUrl?: string; /** 是否支持 markdown 消息 */ markdownSupport?: boolean; + /** 是否启用流式消息(仅 c2c 私聊支持),默认 true */ + streamEnabled?: boolean; config: QQBotAccountConfig; } @@ -43,6 +45,8 @@ export interface QQBotAccountConfig { imageServerBaseUrl?: string; /** 是否支持 markdown 消息,默认 true */ markdownSupport?: boolean; + /** 是否启用流式消息,默认 true(仅 c2c 私聊支持) */ + streamEnabled?: boolean; } /** diff --git a/upgrade-and-run.sh b/upgrade-and-run.sh index b47c279..fe70bd0 100755 --- a/upgrade-and-run.sh +++ b/upgrade-and-run.sh @@ -29,7 +29,7 @@ openclaw plugins install . echo "" echo "[3/4] 配置机器人通道..." # 默认 token,可通过环境变量 QQBOT_TOKEN 覆盖 -QQBOT_TOKEN="${QQBOT_TOKEN:-xxx:xxx}" +QQBOT_TOKEN="${QQBOT_TOKEN:-102831906:tOtPvS0Y7gGqR2eGtXBqVBrYFxfO8sdO}" openclaw channels add --channel qqbot --token "$QQBOT_TOKEN" # 启用 markdown 支持 openclaw config set channels.qqbot.markdownSupport true