sintbot 1.0.15 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.json +118 -0
- package/CHANGELOG.json +118 -0
- package/README.md +20 -156
- package/README.zh-CN.md +20 -164
- package/THIRD-PARTY-NOTICES.md +394 -0
- package/bin/sintbot-v2.js +363 -0
- package/docs/AI-ASSISTED-INSTALL.md +19 -0
- package/docs/install.md +93 -0
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +19 -0
- package/docs/zh-CN/install.md +93 -0
- package/install.sh +84 -0
- package/package.json +74 -34
- package/src/agent/claudecode.js +149 -108
- package/src/agent/codexapp.js +458 -0
- package/src/agent/codexcode.js +210 -107
- package/src/agent/errtag.js +34 -0
- package/src/agent/privatecore.js +790 -0
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +10 -8
- package/src/agent/winexec.js +35 -5
- package/src/c2install.js +117 -0
- package/src/config.js +2 -1
- package/src/core/c2cardeffectbridge.js +51 -0
- package/src/core/c2cardterminalpath.js +216 -0
- package/src/core/c2durableingress.js +148 -0
- package/src/core/c2durablejournal.js +1560 -0
- package/src/core/c2durableruntime.js +2361 -0
- package/src/core/c2eventidentity.js +90 -0
- package/src/core/c2globalfault.js +401 -0
- package/src/core/c2localeffectexecutor.js +193 -0
- package/src/core/c2payloadhandles.js +333 -0
- package/src/core/c2persistrelation.js +179 -0
- package/src/core/c2persiststate.js +342 -0
- package/src/core/c2platformrecoverysender.js +76 -0
- package/src/core/c2recoverygate.js +385 -0
- package/src/core/c2runnerinput.js +63 -0
- package/src/core/c2servicestartgate.js +103 -0
- package/src/core/c2sessionbridge.js +72 -0
- package/src/core/c2sourcequarantine.js +30 -0
- package/src/core/c2startuprecovery.js +105 -0
- package/src/core/c2successorbridge.js +151 -0
- package/src/core/c2terminaleffectbridge.js +154 -0
- package/src/core/c2terminalprune.js +108 -0
- package/src/core/c2turnprojection.js +177 -0
- package/src/core/enginecheckpoint.js +211 -0
- package/src/core/engineeffectcursor.js +394 -0
- package/src/core/enginestepcoordinator.js +183 -0
- package/src/core/store.js +52 -8
- package/src/core/v2engine.js +1469 -0
- package/src/platform/feishu.js +324 -67
- package/src/plugins.js +1 -9
- package/assets/design-styles/acid-pop.md +0 -155
- package/assets/design-styles/archive-index.md +0 -161
- package/assets/design-styles/art-deco.md +0 -164
- package/assets/design-styles/cinematic-noir.md +0 -178
- package/assets/design-styles/editorial.md +0 -121
- package/assets/design-styles/extra/README.md +0 -23
- package/assets/design-styles/extra/constructivism.md +0 -146
- package/assets/design-styles/extra/neo-brutalism.md +0 -175
- package/assets/design-styles/extra/new-wave.md +0 -166
- package/assets/design-styles/extra/nordic-material.md +0 -162
- package/assets/design-styles/extra/paris-couture.md +0 -162
- package/assets/design-styles/extra/tropical-modern.md +0 -168
- package/assets/design-styles/extra/ulm-functional.md +0 -182
- package/assets/design-styles/extra/wa-shikimen.md +0 -160
- package/assets/design-styles/extra/y2k-chrome.md +0 -172
- package/assets/design-styles/geometric-pop.md +0 -122
- package/assets/design-styles/hand-drawn.md +0 -117
- package/assets/design-styles/islamic-geometry.md +0 -155
- package/assets/design-styles/liquid-glass.md +0 -183
- package/assets/design-styles/neo-chinese.md +0 -136
- package/assets/design-styles/precision-mono.md +0 -156
- package/assets/design-styles/quiet-luxury.md +0 -163
- package/assets/design-styles/riso-print.md +0 -116
- package/assets/design-styles/soft-friendly.md +0 -174
- package/assets/design-styles/swiss-grid.md +0 -156
- package/assets/design-styles/tech-dark.md +0 -182
- package/assets/favicon.svg +0 -6
- package/assets/logo.svg +0 -24
- package/bin/sbot.js +0 -4711
- package/docs/slack-app-manifest.yaml +0 -104
- package/sbot.config.example.json +0 -235
- package/src/agent/askonce.js +0 -76
- package/src/agent/claudemodels.js +0 -60
- package/src/agent/claudestatus.js +0 -74
- package/src/agent/claudeusage.js +0 -200
- package/src/agent/codexstatus.js +0 -181
- package/src/agent/imagen.js +0 -458
- package/src/agent/quotaerr.js +0 -44
- package/src/agent/transienterr.js +0 -32
- package/src/cdp.js +0 -254
- package/src/clilang.js +0 -31
- package/src/core/cron.js +0 -182
- package/src/core/cronfire.js +0 -120
- package/src/core/cronrun.js +0 -116
- package/src/core/engine.js +0 -2762
- package/src/core/interfaces.js +0 -114
- package/src/core/issues.js +0 -185
- package/src/core/ledger.js +0 -30
- package/src/core/opsalert.js +0 -214
- package/src/core/pending.js +0 -123
- package/src/core/proxy.js +0 -424
- package/src/core/quotaalert.js +0 -79
- package/src/core/quotaerr.js +0 -55
- package/src/core/scheduler.js +0 -59
- package/src/design.js +0 -5161
- package/src/gitrepo.js +0 -150
- package/src/health.js +0 -353
- package/src/installdeps.js +0 -72
- package/src/leftover.js +0 -174
- package/src/platform/boardnotice.js +0 -77
- package/src/platform/boardops.js +0 -36
- package/src/platform/boardwatch.js +0 -447
- package/src/platform/cli.js +0 -45
- package/src/platform/slack.js +0 -1289
- package/src/platform/takeovercard.js +0 -37
- package/src/platform/wecom.js +0 -938
- package/src/platform/wecomcli.js +0 -128
- package/src/platform/wecomgate.js +0 -108
- package/src/platform/wecomname.js +0 -43
- package/src/platform/wecompoll.js +0 -241
- package/src/preview.js +0 -581
- package/src/projectmemory.js +0 -175
- package/src/service.js +0 -317
- package/src/ui/auth.js +0 -31
- package/src/ui/browser.html +0 -392
- package/src/ui/browserroutes.js +0 -234
- package/src/ui/console.html +0 -5494
- package/src/ui/dashboard.js +0 -168
- package/src/ui/feishulogin.js +0 -136
- package/src/ui/server.js +0 -2314
- package/src/ui/sessions.js +0 -78
- package/src/ui/takeover.js +0 -108
- package/src/updater.js +0 -243
- package/src/upgrade.js +0 -35
- package/src/usage.js +0 -144
package/src/platform/wecom.js
DELETED
|
@@ -1,938 +0,0 @@
|
|
|
1
|
-
// 企业微信「智能机器人(API 模式)」长连接适配器 —— 满足 src/core/interfaces.js 的 Platform 契约。
|
|
2
|
-
// core 不认识本文件,只认接口形状;bin 按配置 `platform:"wecom"` 选用它(否则仍是飞书)。
|
|
3
|
-
//
|
|
4
|
-
// 协议(2026-10-10 真机逐项实测,见 docs/WECOM.md):
|
|
5
|
-
// · bot 主动连 wss://openws.work.weixin.qq.com,发 aibot_subscribe{bot_id,secret} 订阅;不需要公网、
|
|
6
|
-
// 域名、可信 IP、加解密 —— 家庭宽带动态 IP 直接能跑。
|
|
7
|
-
// · 每 30s 发 ping;回包与请求靠 headers.req_id 配对。⚠ 判活只认 ping 的回包:真机上首连一小时后
|
|
8
|
-
// 服务端静默停投,TCP 仍是 ESTABLISHED、没有 close / error 事件 —— 75s 没回包就主动掐线重连。
|
|
9
|
-
// · 收:aibot_msg_callback(群里只有 @ 机器人的消息才来;单聊全来)、aibot_event_callback(进入会话 /
|
|
10
|
-
// 卡片按钮 / 反馈 / 被另一条连接踢下线)。断线期间的消息**不补发**。
|
|
11
|
-
// · 回:aibot_respond_msg 透传回调的 req_id(同一 req_id 24h 内可多次回复);流式 = 同一 stream.id
|
|
12
|
-
// 全量替换、finish=true 收尾,**首帧起 10 分钟内必须收尾**(超了 846608)。
|
|
13
|
-
// · 推:aibot_send_msg{chatid, chat_type 1 单聊 / 2 群} 只认 markdown / template_card / 图 / 文件 /
|
|
14
|
-
// 语音 / 视频(text、stream 都是 40008);前提 = 该会话有人先给机器人发过消息。
|
|
15
|
-
// · markdown ≤ 20480 字节(超了 40058,且那条错误回包**不带 req_id**)。
|
|
16
|
-
// · 同一个机器人只许一条长连接:新连踢旧连(旧连收到 disconnected_event)。
|
|
17
|
-
//
|
|
18
|
-
// 不在本文件做的事(与飞书适配器对齐的边界):撤回事件(企微没有)· 群历史(走官方 CLI)·
|
|
19
|
-
// 发送者姓名的权威来源(CLI 搜人,这里只做缓存与后台预热)。
|
|
20
|
-
|
|
21
|
-
const fs = require('node:fs')
|
|
22
|
-
const os = require('node:os')
|
|
23
|
-
const path = require('node:path')
|
|
24
|
-
const crypto = require('node:crypto')
|
|
25
|
-
const https = require('node:https')
|
|
26
|
-
const http = require('node:http')
|
|
27
|
-
const { execFile } = require('node:child_process')
|
|
28
|
-
const { HISTORY_DAYS, fmtBeijing, createWecomCli } = require('./wecomcli') // 群历史可查天数(6:整 7 天踩边界被拒)· 时间字面量按北京 · 工具封装(轮询历史用)
|
|
29
|
-
const { createHistoryPoll, DUP_WINDOW_MS } = require('./wecompoll')
|
|
30
|
-
const { mentionsOf } = require('./wecomname') // 机器人名从群消息里的 @ 学(平台没有取机器人信息的接口) // 「不 @ 也回复」:轮询群历史补入站 + 规则门控
|
|
31
|
-
const { createWatchdog } = require('./wswatchdog')
|
|
32
|
-
const NAME_WAIT_MS = 5_000 // 首次见到某人时最多等搜人这么久(真机搜人 1~3 秒);超时先显示账号
|
|
33
|
-
const { scanTables, cleanCell } = require('../core/mdtable')
|
|
34
|
-
|
|
35
|
-
const WS_URL = 'wss://openws.work.weixin.qq.com'
|
|
36
|
-
const PING_MS = 30_000 // 文档建议的心跳间隔
|
|
37
|
-
const STALE_MS = 75_000 // 连续两个 ping 没回包 ⇒ 判死(SDK 口径是 12s,这里放宽免误杀)
|
|
38
|
-
const TICK_MS = 15_000 // 看门狗巡检(卡在非连接态过久 ⇒ 强制重建)
|
|
39
|
-
const MD_MAX_BYTES = 20_480 // markdown.content 上限(服务端 40058)
|
|
40
|
-
const MD_SAFE_BYTES = 19_000 // 自留余量:切片时按它切,免得边界上多一个多字节字符就被拒
|
|
41
|
-
const STREAM_ROLL_MS = 9 * 60_000 // 流式 10 分钟硬上限 ⇒ 9 分钟就收掉这一段、另起一段接着流
|
|
42
|
-
const RESPOND_WINDOW_MS = 23 * 3600_000 // 回调 req_id 的可回复窗口(文档 24h,自留 1h)
|
|
43
|
-
const MEDIA_MAX_BYTES = 100 * 1024 * 1024 // 入站媒体上限(文档:文件 / 视频 ≤ 100MB)
|
|
44
|
-
|
|
45
|
-
// ── 纯函数:入站归一 ───────────────────────────────────────────────────────────────
|
|
46
|
-
|
|
47
|
-
// 去掉正文里对机器人的 @:群聊回调的 text.content 形如「@SintBot 帮我看下」—— @ 文本混在内容里,
|
|
48
|
-
// 不剥的话每轮 prompt 开头都是一个无意义的提及。知道机器人名字就按名字剥(任意位置、可多次);
|
|
49
|
-
// 不知道就只剥开头那一个(群里能到的消息必然 @ 了它,开头那个 @ 十有八九是它)。
|
|
50
|
-
// 去重键归一:多段文字之间长连接用换行拼、历史接口用空格拼,引用只在长连接有 —— 不归一就对不上(对不上 = 同一条 @ 消息答两遍)
|
|
51
|
-
const plainKey = (parts) => parts.join(' ').replace(/\s+/g, ' ').trim()
|
|
52
|
-
|
|
53
|
-
// 零宽字符(U+200B~200D / 2060 / FEFF):手机端复制粘贴常夹带,肉眼看不见却会进 prompt、也会让去重键对不上 —— 一律去掉
|
|
54
|
-
const ZERO_WIDTH_RE = /[\u200b-\u200d\u2060\ufeff]/g
|
|
55
|
-
const reEscape = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
56
|
-
|
|
57
|
-
function stripMention(text, botName) {
|
|
58
|
-
let t = String(text == null ? '' : text).replace(ZERO_WIDTH_RE, '')
|
|
59
|
-
// 名字也要去零宽:学来的名字可能带着它(wecomname 入口已剥,这里再兜一层 —— 正文剥了名字没剥 = 永远匹配不上)
|
|
60
|
-
if (botName) botName = String(botName).replace(ZERO_WIDTH_RE, '')
|
|
61
|
-
// 按名剥要带词边界:「@SintBot2」是另一个成员,不能剥成「2」。边界 = 名字后面不是字母 / 数字 / _ / -
|
|
62
|
-
// (空白、句尾、标点、汉字都算边界 —— 「@小助手帮我看看」这种名字后面直接接汉字的也要剥得掉)
|
|
63
|
-
if (botName) t = t.replace(new RegExp('@' + reEscape(botName) + '(?![A-Za-z0-9_\\-])', 'g'), ' ')
|
|
64
|
-
else t = t.replace(/^(\s*@\S+)+\s*/, ' ') // 「@张三 @SintBot 看看」:开头连着的提及一起剥,免得剥错人
|
|
65
|
-
return t.replace(/[ \t]+/g, ' ').replace(/^ +| +$/gm, '').trim()
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// 入站帧 ⇒ 对象。平台会把成员输入原样塞进 JSON 字符串,含原始控制字符(U+000B 之类)的帧 JSON.parse 直接抛
|
|
69
|
-
// (官方 SDK 曾因此一条消息打死整条连接)。三步:原样解析 → 失败就剔掉控制字符重试(先不碰 \t \n \r:它们在 token 之间
|
|
70
|
-
// 是合法空白,直接删会把 `1\n}` 这种粘成坏 JSON)→ 还失败再把 \t \n \r 换成空格重试(串里原始 TAB 同样非法 —— 从表格复制
|
|
71
|
-
// 粘贴就带;token 之间换空格无害,串内换空格只丢排版不丢消息)。三次都不行才抛 —— 调用方丢这一帧并记日志,连接不受影响。
|
|
72
|
-
function parseFrame(raw) {
|
|
73
|
-
const s = String(raw)
|
|
74
|
-
try { return JSON.parse(s) } catch (e) {
|
|
75
|
-
const cleaned = s.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
|
|
76
|
-
try { if (cleaned !== s) return JSON.parse(cleaned) } catch { /* 再试一次 */ }
|
|
77
|
-
const spaced = cleaned.replace(/[\t\n\r]/g, ' ')
|
|
78
|
-
if (spaced === cleaned) throw e
|
|
79
|
-
return JSON.parse(spaced)
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// 一条回调 body ⇒ 引擎能吃的形状(media 另给,由调用方决定下不下载)。什么都没有 ⇒ null。
|
|
84
|
-
// 返回 { chatId, chatType('single'|'group'), userId, msgId, createTimeMs, text, media:[{kind,url,aeskey,name}] }
|
|
85
|
-
// · text:文本 / 图文混排的文字 / 语音转写(前缀标明)/ 引用(附在末尾)
|
|
86
|
-
// · media:图片 / 文件 / 视频的加密下载坐标(url 五分钟内有效,aeskey 解密)
|
|
87
|
-
function normalizeInbound(body, { botName } = {}) {
|
|
88
|
-
if (!body || typeof body !== 'object') return null
|
|
89
|
-
const chatType = body.chattype === 'group' ? 'group' : 'single'
|
|
90
|
-
const userId = (body.from && body.from.userid) || 'unknown'
|
|
91
|
-
const chatId = chatType === 'group' ? body.chatid : userId
|
|
92
|
-
if (!chatId) return null
|
|
93
|
-
const parts = []
|
|
94
|
-
const media = []
|
|
95
|
-
const takeMedia = (kind, obj, name) => { if (obj && obj.url) media.push({ kind, url: obj.url, aeskey: obj.aeskey || null, name: name || null }) }
|
|
96
|
-
const eat = (item) => {
|
|
97
|
-
if (!item || typeof item !== 'object') return
|
|
98
|
-
switch (item.msgtype) {
|
|
99
|
-
case 'text': if (item.text && item.text.content) parts.push(stripMention(item.text.content, botName)); break
|
|
100
|
-
case 'image': takeMedia('image', item.image); break
|
|
101
|
-
case 'file': takeMedia('file', item.file, item.file && item.file.filename); break
|
|
102
|
-
case 'video': takeMedia('video', item.video); break
|
|
103
|
-
case 'voice': if (item.voice && item.voice.content) parts.push('(语音转文字)' + String(item.voice.content).replace(ZERO_WIDTH_RE, '')); break
|
|
104
|
-
case 'mixed': for (const sub of (item.mixed && item.mixed.msg_item) || []) eat(sub); break
|
|
105
|
-
default: break
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
eat(body)
|
|
109
|
-
const ownParts = parts.length // 引用之前的片段数:去重键只认本条自己的文字
|
|
110
|
-
// 引用的那条:原文给出来(agent 常被要求「看我引用的这条」);引用里的媒体一并收进 media。
|
|
111
|
-
if (body.quote && typeof body.quote === 'object') {
|
|
112
|
-
const q = body.quote
|
|
113
|
-
const before = parts.length
|
|
114
|
-
const qm = media.length
|
|
115
|
-
eat(q)
|
|
116
|
-
const qText = parts.splice(before).join(' ').trim()
|
|
117
|
-
const qMedia = media.splice(qm)
|
|
118
|
-
if (qText || qMedia.length) parts.push(`「引用:${qText || '(' + qMedia.map((m) => m.kind === 'image' ? '图片' : '文件').join('、') + ')'}」`)
|
|
119
|
-
media.push(...qMedia)
|
|
120
|
-
}
|
|
121
|
-
const text = parts.join('\n').trim()
|
|
122
|
-
if (!text && !media.length) return null
|
|
123
|
-
return {
|
|
124
|
-
chatId,
|
|
125
|
-
chatType,
|
|
126
|
-
userId,
|
|
127
|
-
msgId: body.msgid || `${chatId}:${body.create_time || Date.now()}`,
|
|
128
|
-
createTimeMs: body.create_time ? Number(body.create_time) * 1000 : Date.now(),
|
|
129
|
-
text,
|
|
130
|
-
plain: plainKey(parts.slice(0, ownParts)), // 去重键:只有本条的文字段(不含「引用:…」),空白归一 —— 与轮询侧 wecompoll.plainText 同口径
|
|
131
|
-
media,
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ── 纯函数:出站 markdown ──────────────────────────────────────────────────────────
|
|
136
|
-
|
|
137
|
-
// 企微群里的回复长什么样,全由这一个函数定(v0.11.113 重排;此前只转表格 + 剥 <font>)。企微 aibot markdown 真机实测
|
|
138
|
-
// (10-10 / 10-12,docs/WECOM.md):粗体 / 列表 / 行内代码 / 链接 / 引用渲染;**表格与跨行代码块不渲染**(竖线与 ``` 原样
|
|
139
|
-
// 露出);HTML 标签一概不认(`<font color="comment">` 曾原样露在群里 —— 10-12 截图实证)。按这些事实逐项降级:
|
|
140
|
-
// · 表格 ⇒ 表头一行加粗,每行一个列表项、单元格用「 · 」连;列数 ≥4 时给每格带上表头名,否则一串数字对不上列
|
|
141
|
-
// · 代码块 ⇒ 去围栏,每行各自包成行内代码(等宽 + 保护 # * 等符号不被当 markdown;空行保留,单行里有反引号的用双反引号包)
|
|
142
|
-
// · 标题 ⇒ 加粗行(聊天气泡里一级标题大得离谱,而回答至多一层结构,加粗足够;围栏内的 # 不动)
|
|
143
|
-
// · HTML 标签(font / b / i / u / s / span / strong / em)⇒ 只留文字;<br> ⇒ 换行
|
|
144
|
-
// 顺序有讲究:表格扫描自带围栏识别,先转;再逐行走围栏 —— 围栏内的行包成行内代码后不会再被当标题 / 标签处理。
|
|
145
|
-
const INLINE_TAG_RE = /<\/?(?:font|b|i|u|s|span|strong|em)\b[^>]*>/gi
|
|
146
|
-
function codeLine(l) {
|
|
147
|
-
if (!l.trim()) return ''
|
|
148
|
-
return l.indexOf('`') === -1 ? '`' + l + '`' : '`` ' + l + ' ``'
|
|
149
|
-
}
|
|
150
|
-
function mdForWecom(text) {
|
|
151
|
-
const src = String(text == null ? '' : text)
|
|
152
|
-
const lines = []
|
|
153
|
-
for (const seg of scanTables(src)) {
|
|
154
|
-
if (seg.kind === 'text') { lines.push(...seg.text.split('\n')); continue }
|
|
155
|
-
const header = seg.header.map(cleanCell)
|
|
156
|
-
lines.push('**' + header.join(' · ') + '**')
|
|
157
|
-
for (const r of seg.rows) {
|
|
158
|
-
const cells = r.map(cleanCell)
|
|
159
|
-
lines.push('- ' + (header.length >= 4 ? cells.map((c, i) => (i === 0 || !header[i] ? c : header[i] + ' ' + c)).join(' · ') : cells.join(' · ')))
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
const out = []
|
|
163
|
-
let fence = false
|
|
164
|
-
for (const line of lines) {
|
|
165
|
-
if (/^\s*(```|~~~)/.test(line)) { fence = !fence; continue } // 围栏行本身丢掉(语言名也丢:气泡里没地方放)
|
|
166
|
-
if (fence) { out.push(codeLine(line)); continue }
|
|
167
|
-
let l = line.replace(/<br\s*\/?>/gi, '\n').replace(INLINE_TAG_RE, '')
|
|
168
|
-
l = l.replace(/^(\s*)#{1,6}\s+(.+?)\s*#*\s*$/, (_m, ind, t) => ind + '**' + t.replace(/^\*\*(.*)\*\*$/, '$1') + '**')
|
|
169
|
-
out.push(l)
|
|
170
|
-
}
|
|
171
|
-
return out.join('\n')
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// 按字节上限切片(UTF-8),尽量在行边界切;单行超长就硬切。空文本 ⇒ []。
|
|
175
|
-
function splitMarkdown(text, maxBytes = MD_SAFE_BYTES) {
|
|
176
|
-
const src = String(text == null ? '' : text)
|
|
177
|
-
if (!src) return []
|
|
178
|
-
if (Buffer.byteLength(src) <= maxBytes) return [src]
|
|
179
|
-
// 单行硬切:按码点走(emoji 等代理对不能从中间切开,切开两半都是非法 UTF-16),累计字节到上限为止
|
|
180
|
-
const cutAt = (line) => { let bytes = 0, idx = 0; for (const ch of line) { const b = Buffer.byteLength(ch); if (bytes + b > maxBytes) break; bytes += b; idx += ch.length } return idx }
|
|
181
|
-
const chunks = []
|
|
182
|
-
let cur = ''
|
|
183
|
-
const flush = () => { if (cur) { chunks.push(cur); cur = '' } }
|
|
184
|
-
for (const line of src.split('\n')) {
|
|
185
|
-
let l = line
|
|
186
|
-
while (Buffer.byteLength(l) > maxBytes) { const cut = cutAt(l); flush(); chunks.push(l.slice(0, cut)); l = l.slice(cut) }
|
|
187
|
-
const next = cur ? cur + '\n' + l : l
|
|
188
|
-
if (Buffer.byteLength(next) > maxBytes) { flush(); cur = l } else cur = next
|
|
189
|
-
}
|
|
190
|
-
flush()
|
|
191
|
-
return chunks
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// ── 纯函数:入站媒体解密 ─────────────────────────────────────────────────────────
|
|
195
|
-
|
|
196
|
-
// 文档:AES-256-CBC,PKCS#7 填充到 32 字节的倍数,IV = AESKey 前 16 字节;aeskey 与回调加解密的
|
|
197
|
-
// EncodingAESKey 同形(43 字符 base64 去掉了「=」)。解不出来就原样返回(宁可给出密文让人排查,也不丢)。
|
|
198
|
-
function decodeAesKey(aeskey) {
|
|
199
|
-
const s = String(aeskey || '').trim()
|
|
200
|
-
if (!s) return null
|
|
201
|
-
let key = Buffer.from(s + '='.repeat((4 - (s.length % 4)) % 4), 'base64')
|
|
202
|
-
if (key.length === 32) return key
|
|
203
|
-
// 也可能直接给了 32 字节的十六进制 / 原文:只认长度对的
|
|
204
|
-
if (s.length === 64 && /^[0-9a-f]+$/i.test(s)) return Buffer.from(s, 'hex')
|
|
205
|
-
if (s.length === 32) return Buffer.from(s, 'latin1')
|
|
206
|
-
return key.length >= 32 ? key.subarray(0, 32) : null
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// 解不出(key 形状不对 / 长度不是 16 的倍数 / 填充不合法)返回 null —— 调用方按「下载失败」处理;
|
|
210
|
-
// 不能把密文原样存成 .png 交给 agent(那只会让它对着乱码说「图片损坏」)。
|
|
211
|
-
function decryptMedia(buf, aeskey) {
|
|
212
|
-
const key = decodeAesKey(aeskey)
|
|
213
|
-
if (!key || !buf || !buf.length || buf.length % 16 !== 0) return null
|
|
214
|
-
try {
|
|
215
|
-
const d = crypto.createDecipheriv('aes-256-cbc', key, key.subarray(0, 16))
|
|
216
|
-
d.setAutoPadding(false)
|
|
217
|
-
const out = Buffer.concat([d.update(buf), d.final()])
|
|
218
|
-
const pad = out[out.length - 1]
|
|
219
|
-
// PKCS#7 校验:填充字节必须全等于填充长度(错 key 解出来的尾巴十有八九过不了这关)
|
|
220
|
-
if (!(pad >= 1 && pad <= 32 && pad <= out.length)) return null
|
|
221
|
-
for (let k = out.length - pad; k < out.length; k++) if (out[k] !== pad) return null
|
|
222
|
-
return out.subarray(0, out.length - pad)
|
|
223
|
-
} catch { return null }
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// 加密(只给单测造数据用,与上面严格互逆)
|
|
227
|
-
function encryptMedia(buf, aeskey) {
|
|
228
|
-
const key = decodeAesKey(aeskey)
|
|
229
|
-
const pad = 32 - (buf.length % 32)
|
|
230
|
-
const padded = Buffer.concat([buf, Buffer.alloc(pad, pad)])
|
|
231
|
-
const c = crypto.createCipheriv('aes-256-cbc', key, key.subarray(0, 16))
|
|
232
|
-
c.setAutoPadding(false)
|
|
233
|
-
return Buffer.concat([c.update(padded), c.final()])
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// 按魔数猜扩展名(解密后的内容);猜不出用原名的扩展名,再不行 .bin
|
|
237
|
-
function sniffExt(buf, name) {
|
|
238
|
-
if (buf && buf.length >= 4) {
|
|
239
|
-
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return '.png'
|
|
240
|
-
if (buf[0] === 0xff && buf[1] === 0xd8) return '.jpg'
|
|
241
|
-
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return '.gif'
|
|
242
|
-
if (buf.length >= 12 && buf.subarray(8, 12).toString('latin1') === 'WEBP') return '.webp'
|
|
243
|
-
if (buf[0] === 0x25 && buf[1] === 0x50 && buf[2] === 0x44 && buf[3] === 0x46) return '.pdf'
|
|
244
|
-
}
|
|
245
|
-
const e = name ? path.extname(name) : ''
|
|
246
|
-
return e && e.length <= 8 ? e : '.bin'
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// 媒体标记:与飞书那条「[图片 message_id=… key=…]」同一用途(让 agent 接得住「我发给你的图」),
|
|
250
|
-
// 坐标换成本机路径 —— 企微的下载链接五分钟就失效,不能留给 agent 事后再取,只能到手就落盘。
|
|
251
|
-
function mediaMark(kind, p) {
|
|
252
|
-
const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '文件'
|
|
253
|
-
return `[${label} path=${p}]`
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// ── 流式回复会话(可单测:send / now / 定时器全注入)────────────────────────────────
|
|
257
|
-
|
|
258
|
-
// 引擎的 StreamingCard 契约:update(全量累计文本) · finish(最终正文, {milestones, footer})。
|
|
259
|
-
// 企微流式是「同一 stream.id 全量替换」+「首帧起 10 分钟内必须 finish」+「单帧 ≤ 20480 字节」。sbot 一轮常常超过
|
|
260
|
-
// 10 分钟、正文也可能超长 ⇒ **到龄或超长时分段**:把这一段**已经发出去**的内容 finish 定格,再对同一个 req_id 另起一个
|
|
261
|
-
// stream.id 续流,只带从断点往后的文字(前缀「(接上条)」)。
|
|
262
|
-
// ⚠ 引擎给 update 的是「正文 + 里程碑行」,给 finish 的是剥掉里程碑的正文 —— 两串不同,不能拿前者算出的断点去切后者
|
|
263
|
-
// (审查 H1:那样会把最终答案切在随机位置)。收尾时只有最终正文**确实以定格文本开头**才续断点,否则整段重发。
|
|
264
|
-
// 节流:两帧之间至少 throttleMs,窗内只记最后一帧,窗到补发(累计文本幂等,丢中间帧无损)。
|
|
265
|
-
function createStreamSession({ send, sendMarkdown, now = () => Date.now(), setTimer = setTimeout, clearTimer = clearTimeout, throttleMs = 800, rollMs = STREAM_ROLL_MS, maxBytes = MD_SAFE_BYTES, log = () => {}, newId = () => 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8) } = {}) {
|
|
266
|
-
let streamId = null
|
|
267
|
-
let segStart = 0 // 当前段首帧时间
|
|
268
|
-
let offset = 0 // 当前段在(转换后的)累计文本里的起点
|
|
269
|
-
let frozen = '' // 上一段定格时的全文(= 累计文本的前 offset 个字符)
|
|
270
|
-
let lastSent = '' // 本段最近一次发出去的全文(转换后)
|
|
271
|
-
let sentInSeg = false // 本段发过帧没有(没发过的段不需要 finish)
|
|
272
|
-
let pending = null // 节流窗内挂起的文本
|
|
273
|
-
let timer = null
|
|
274
|
-
let lastSentAt = -Infinity
|
|
275
|
-
let closed = false
|
|
276
|
-
let chain = Promise.resolve()
|
|
277
|
-
let segments = 0
|
|
278
|
-
|
|
279
|
-
const frame = (content, finish) => {
|
|
280
|
-
const id = streamId
|
|
281
|
-
// 内容空串服务端会拒,占个省略号(首帧往往只有一个换行)
|
|
282
|
-
chain = chain.then(() => send({ msgtype: 'stream', stream: { id, finish, content: content || '…' } })).then((ack) => {
|
|
283
|
-
if (ack && ack.errcode) log(`流式帧被拒(${ack.errcode}): ${ack.errmsg || ''}`)
|
|
284
|
-
return ack
|
|
285
|
-
}).catch((e) => log('流式帧发送异常: ' + (e && e.message)))
|
|
286
|
-
return chain
|
|
287
|
-
}
|
|
288
|
-
const open = () => { streamId = newId(); segStart = now(); segments++; sentInSeg = false }
|
|
289
|
-
const segText = (full) => (offset ? '(接上条)' + full.slice(offset) : full.slice(offset))
|
|
290
|
-
const tooBig = (t) => Buffer.byteLength(t) > maxBytes
|
|
291
|
-
// 分段:把本段已发的内容定格(它一定不超长 —— 发之前就截过),从它的末尾另起一段
|
|
292
|
-
const roll = () => { frame(segText(lastSent), true); frozen = lastSent; offset = lastSent.length; open() }
|
|
293
|
-
|
|
294
|
-
const push = (raw) => {
|
|
295
|
-
if (closed) return
|
|
296
|
-
const full = mdForWecom(raw)
|
|
297
|
-
if (!streamId) open()
|
|
298
|
-
else if (sentInSeg && (now() - segStart >= rollMs || tooBig(segText(full)))) roll()
|
|
299
|
-
let seg = segText(full)
|
|
300
|
-
if (tooBig(seg)) seg = splitMarkdown(seg, maxBytes)[0] || '…' // 单次更新就超长:先发能放下的前半当预览,收尾会把全文分片发
|
|
301
|
-
lastSent = full; sentInSeg = true; lastSentAt = now()
|
|
302
|
-
frame(seg, false)
|
|
303
|
-
}
|
|
304
|
-
const flush = () => { timer = null; if (pending != null) { const t = pending; pending = null; push(t) } }
|
|
305
|
-
|
|
306
|
-
return {
|
|
307
|
-
update(text) {
|
|
308
|
-
if (closed) return
|
|
309
|
-
const t = String(text == null ? '' : text)
|
|
310
|
-
if (t === '') return
|
|
311
|
-
const wait = throttleMs - (now() - lastSentAt)
|
|
312
|
-
if (wait <= 0) { pending = null; push(t); return }
|
|
313
|
-
pending = t
|
|
314
|
-
if (!timer) timer = setTimer(flush, wait)
|
|
315
|
-
},
|
|
316
|
-
async finish(text, opts = {}) {
|
|
317
|
-
if (closed) return
|
|
318
|
-
closed = true
|
|
319
|
-
if (timer) { clearTimer(timer); timer = null }
|
|
320
|
-
pending = null
|
|
321
|
-
const full = mdForWecom(String(text == null ? '' : text))
|
|
322
|
-
// 续断点的前提:最终正文确实以定格过的文本开头;否则(里程碑行被剥、表格被转……)整段重发,宁可重复不可缺
|
|
323
|
-
let body = !offset ? full : (full.startsWith(frozen) ? (full.length > frozen.length ? '(接上条)' + full.slice(frozen.length) : '') : full)
|
|
324
|
-
// 页脚(耗时 · tokens)做成引用行:企微不认飞书那套 <font color="comment">(10-12 真机原样露出),引用是它能渲染的唯一「次要文字」样式
|
|
325
|
-
if (opts.footer && (body || sentInSeg)) body = (body ? body + '\n\n' : '') + '> ' + String(opts.footer).replace(/\n+/g, ' ')
|
|
326
|
-
if (!body && !sentInSeg) {
|
|
327
|
-
if (!streamId) { open(); await frame('(无内容)', true) } // 一帧都没推过、正文也空:给一句交代,别让人干等
|
|
328
|
-
return // 刚分完段、新段没发过帧、也没有新内容 ⇒ 什么都不用发(上一段已定格)
|
|
329
|
-
}
|
|
330
|
-
if (!streamId) open()
|
|
331
|
-
const chunks = splitMarkdown(body, maxBytes)
|
|
332
|
-
const first = chunks.shift() || ''
|
|
333
|
-
await frame(first || '(无内容)', true)
|
|
334
|
-
for (const c of chunks) { try { await sendMarkdown(c) } catch (e) { log('收尾续片发送失败: ' + (e && e.message)) } }
|
|
335
|
-
},
|
|
336
|
-
// 给单测与 connHealth 看的内部状态
|
|
337
|
-
_state: () => ({ streamId, offset, segments, closed, pending: pending != null }),
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// ── 适配器 ─────────────────────────────────────────────────────────────────────
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* @param {object} o
|
|
345
|
-
* botId / secret — 机器人 API 配置页的 Bot ID 与长连接 Secret
|
|
346
|
-
* botName — 机器人名字(剥 @ 用;不填只剥开头那个 @)
|
|
347
|
-
* inboxDir — 入站媒体落盘目录(默认 ~/.sbot/wecom-inbox)
|
|
348
|
-
* names — { userid: 显示名 } 手工映射(优先级最高)
|
|
349
|
-
* cli — 官方命令行工具可执行名(默认 wecom-cli;搜人解姓名用;没装就只显示 userid)
|
|
350
|
-
* welcome — 用户首次进入单聊时回的一句话(空 = 不回)
|
|
351
|
-
* throttleMs — 流式帧节流窗(默认 800ms)
|
|
352
|
-
* pollSec — 「不 @ 也回复」轮询群历史的间隔秒(默认 0 = 关;开要有命令行工具)
|
|
353
|
-
* followSec — 跟进窗口秒(默认 300:被 @ / 开口后这么久内不 @ 的消息也喂;0 = 全喂)
|
|
354
|
-
* pollChats — () => 要轮询的群 id 列表(bin 给已绑的企微群;每 tick 现读)
|
|
355
|
-
* keywords — 点名词(默认「机器人」「助手」;正文含它或机器人名就喂)
|
|
356
|
-
* onIssue — ({issue:'kicked'|'auth-expired'|'tool-gated', detail}) 平台侧要人处理的事(bin 转成运维告警)
|
|
357
|
-
* gate — 便宜模型门控 async (chatId, {text, sender, recent}) => bool(规则不放行的消息再问它;不传 = 没有第二道门)
|
|
358
|
-
* log / onConn — 日志 / 连接状态变化回调(控制台 SSE)
|
|
359
|
-
* WebSocket / wsUrl / now / setTimer / clearTimer / exec / fetchBuffer — 测试注入
|
|
360
|
-
*/
|
|
361
|
-
function createWecomPlatform(o = {}) {
|
|
362
|
-
const { botId, secret } = o
|
|
363
|
-
if (!botId || !secret) throw new Error('wecom: botId and secret required')
|
|
364
|
-
// 机器人名 / 点名词可以给函数(bin 给的是「现读 engine.cfg」):控制台改了名字热生效,不用重启 —— 10-12 用户在控制台改名后
|
|
365
|
-
// 发现要重启才认,而卡上只说过凭据要重启。启动横幅与「已装」日志读一次即可,剥 @ / 点名 / 门控提示词每次现取。
|
|
366
|
-
let learnedName = null // 从群消息的 @ 里学到的名字:配置里没填 / 填错时的依据;控制台「刷新身份」会把它落进配置
|
|
367
|
-
const botNameOf = () => { const v = typeof o.botName === 'function' ? o.botName() : o.botName; return v || learnedName || null }
|
|
368
|
-
// 群消息必 @ 机器人(平台只推这种):正文里恰好只 @ 了一个名字时,那就是它。多个 @(连带别人)不学,免得学成别人
|
|
369
|
-
function learnName(body) {
|
|
370
|
-
if (!body || body.chattype !== 'group') return
|
|
371
|
-
const texts = []
|
|
372
|
-
const eat = (it) => { if (!it) return; if (it.msgtype === 'text' && it.text) texts.push(String(it.text.content || '')); if (it.msgtype === 'mixed') for (const s of (it.mixed && it.mixed.msg_item) || []) eat(s) }
|
|
373
|
-
eat(body)
|
|
374
|
-
const names = [...new Set(texts.flatMap(mentionsOf))]
|
|
375
|
-
if (names.length !== 1) return
|
|
376
|
-
if (learnedName !== names[0]) { learnedName = names[0]; const cfgName = typeof o.botName === 'function' ? o.botName() : o.botName; if (cfgName && cfgName !== learnedName) log(`企业微信:群里 @ 的名字是「${learnedName}」,与配置的「${cfgName}」不一致 —— 剥 @ 按群里的来;到控制台点「刷新身份」可同步`) }
|
|
377
|
-
}
|
|
378
|
-
const keywordsOf = () => { const v = typeof o.keywords === 'function' ? o.keywords() : o.keywords; return Array.isArray(v) ? v : undefined }
|
|
379
|
-
const botName = botNameOf()
|
|
380
|
-
// 配置里写 ~ 很自然(文档示例就是),这里展开;names 用无原型对象,免得 userid 叫 constructor 之类时拿到函数当姓名
|
|
381
|
-
const inboxDir = expandHome(o.inboxDir) || path.join(os.homedir(), '.sbot', 'wecom-inbox')
|
|
382
|
-
const names = Object.assign(Object.create(null), o.names || {})
|
|
383
|
-
const cli = o.cli === false ? null : (o.cli || 'wecom-cli')
|
|
384
|
-
const log = o.log || (() => {})
|
|
385
|
-
const onConn = o.onConn || (() => {})
|
|
386
|
-
const now = o.now || (() => Date.now())
|
|
387
|
-
const setTimer = o.setTimer || setTimeout
|
|
388
|
-
const clearTimer = o.clearTimer || clearTimeout
|
|
389
|
-
const WebSocketImpl = o.WebSocket || require('ws')
|
|
390
|
-
const wsUrl = o.wsUrl || WS_URL
|
|
391
|
-
const exec = o.exec || execFile
|
|
392
|
-
const fetchBuffer = o.fetchBuffer || defaultFetchBuffer
|
|
393
|
-
const throttleMs = Number.isFinite(o.throttleMs) ? o.throttleMs : 800
|
|
394
|
-
// 默认 0 = 关(产品决定「尽量关闭轮询」):平台的设计就是 @ 才答;开了要每 pollSec 秒拉群历史 —— 《企业微信人工智能服务使用规则》
|
|
395
|
-
// 禁自动化抓取与异常频率,且 >10 人企业官方未开放 chat.*(853006),行为会因企业而异。留作小测试群可开的开关
|
|
396
|
-
const pollSec = Number.isFinite(Number(o.pollSec)) && o.pollSec !== undefined && o.pollSec !== null && o.pollSec !== '' ? Number(o.pollSec) : 0
|
|
397
|
-
const followSec = Number.isFinite(Number(o.followSec)) && o.followSec !== undefined && o.followSec !== null && o.followSec !== '' ? Number(o.followSec) : 300
|
|
398
|
-
const recentInbound = new Map() // chatId → [{text,t}] 长连接已投递的 @ 消息(轮询去重用)
|
|
399
|
-
let poll = null
|
|
400
|
-
|
|
401
|
-
let ws = null
|
|
402
|
-
let gen = 0 // 每次建连 +1;旧 socket 的事件按代号丢弃
|
|
403
|
-
let subscribed = false
|
|
404
|
-
let everReady = false
|
|
405
|
-
let stopping = false
|
|
406
|
-
let pingTimer = null, tickTimer = null
|
|
407
|
-
let lastAckAt = 0, lastEventAt = 0
|
|
408
|
-
let kickedAt = 0 // 被另一条连接踢下线的时刻(重连要让一让)
|
|
409
|
-
// 被踢的官方口径是「避免多连接、主备切换」,官方 SDK / 插件被踢后都**不**自动重连;我们给一次机会(60s 后重连一次 —— 对面可能是人手起的调试
|
|
410
|
-
// 连接),10 分钟内第二次被踢就停连并告警:两台机器配了同一个 Bot ID 时自动重连 = 每 60 秒互踢一轮,谁都收不到消息
|
|
411
|
-
const kicks = []
|
|
412
|
-
let halted = false
|
|
413
|
-
const onIssue = (ev) => { try { if (typeof o.onIssue === 'function') o.onIssue(ev) } catch {} }
|
|
414
|
-
let warnedEncUser = false, warnedAuth = -Infinity // -Infinity:首次必报(假时钟从小数起,0 会让「10 分钟一次」的判据把首条压掉)
|
|
415
|
-
// 出站发送在「还没订阅成功」时等一等(最多 SEND_WAIT_MS),不是立刻失败:daemon 一起来就发的升级播报 / 续做告知 /
|
|
416
|
-
// 定时任务直发,比长连接订阅成功早一秒(真机 10-12 11:02:58 播报 → 11:02:59 才订阅)就全部「not connected」丢掉,
|
|
417
|
-
// 而飞书走 HTTP 不受影响 ⇒ 企微群永远收不到升级播报。订阅成功的瞬间把排队的放行;超时才真的放弃(回 null)。
|
|
418
|
-
const AFTER_REPLY_POLL_MS = 4_000 // 机器人答完多久补拉一次群历史(两车道:跟进句通常紧跟在回答后)
|
|
419
|
-
const SEND_WAIT_MS = 30_000
|
|
420
|
-
const subWaiters = []
|
|
421
|
-
const whenSubscribed = (ms) => subscribed ? Promise.resolve(true) : new Promise((resolve) => {
|
|
422
|
-
const w = { done: (ok) => { clearTimer(w.t); const i = subWaiters.indexOf(w); if (i >= 0) subWaiters.splice(i, 1); resolve(ok) }, t: null }
|
|
423
|
-
w.t = setTimer(() => w.done(false), ms); if (w.t && w.t.unref) w.t.unref()
|
|
424
|
-
subWaiters.push(w)
|
|
425
|
-
})
|
|
426
|
-
const releaseWaiters = (ok) => { for (const w of subWaiters.splice(0)) w.done(ok) }
|
|
427
|
-
const waiters = new Map() // req_id → [resolve…](同一 req_id 多次回复 ⇒ 队列)
|
|
428
|
-
const chatTypes = new Map() // chatId → 1 单聊 / 2 群(主动推送要带)
|
|
429
|
-
const atSeen = new Set() // 本进程里真收到过长连接回调(= 有人 @ 过)的会话;轮询到的历史不算 —— 846607 翻人话用
|
|
430
|
-
const nameCache = new Map() // userId → 显示名
|
|
431
|
-
let cliDead = !cli
|
|
432
|
-
let onMessage = null
|
|
433
|
-
let hooks = {}
|
|
434
|
-
const inbound = new Map() // chatId → 处理链:同一会话内串行(媒体下载要 await,串行才不会让后到的消息抢先推水位线),会话之间互不等待
|
|
435
|
-
|
|
436
|
-
const seq = { n: 0 }
|
|
437
|
-
const rid = (p) => `${p}_${now().toString(36)}_${++seq.n}`
|
|
438
|
-
|
|
439
|
-
// 一问一答:按 req_id 配对;没连上 / 超时都 resolve 成 errcode≠0,绝不 reject(调用方按 errcode 判)
|
|
440
|
-
const call = (cmd, body, reqId, timeoutMs = 20_000) => new Promise((resolve) => {
|
|
441
|
-
if (!ws || ws.readyState !== 1) return resolve({ errcode: -1, errmsg: 'not connected' })
|
|
442
|
-
const id = reqId || rid(cmd.replace(/^aibot_/, ''))
|
|
443
|
-
let t = null
|
|
444
|
-
const done = (v) => { if (t) clearTimer(t); resolve(v) } // 回包到了就撤超时定时器,别让它们拖着进程 / 假时钟
|
|
445
|
-
if (!waiters.has(id)) waiters.set(id, [])
|
|
446
|
-
waiters.get(id).push(done)
|
|
447
|
-
try { ws.send(JSON.stringify({ cmd, headers: { req_id: id }, body })) } catch (e) { dropWaiter(id, done); return done({ errcode: -1, errmsg: e && e.message }) }
|
|
448
|
-
t = setTimer(() => { if (dropWaiter(id, done)) resolve({ errcode: -1, errmsg: 'timeout' }) }, timeoutMs)
|
|
449
|
-
})
|
|
450
|
-
// 从等待队列摘掉一个 resolver(超时 / 发送失败时用):同一 req_id 可能排着多个回复在等各自的 ack,只摘自己那个
|
|
451
|
-
const dropWaiter = (id, fn) => { const q = waiters.get(id); if (!q) return false; const i = q.indexOf(fn); if (i < 0) return false; q.splice(i, 1); if (!q.length) waiters.delete(id); return true }
|
|
452
|
-
|
|
453
|
-
// ── 连接生命周期(看门狗:健康快照 + 卡死重建的退避决策;本文件只喂事件)──────────
|
|
454
|
-
const watchdog = createWatchdog({ now, log, onConn, rebuild: () => connect() })
|
|
455
|
-
|
|
456
|
-
function connect() {
|
|
457
|
-
if (stopping) return
|
|
458
|
-
if (halted) { log('企业微信:已因重复被踢停止重连,重启守护进程才会再连'); return }
|
|
459
|
-
const myGen = ++gen
|
|
460
|
-
subscribed = false
|
|
461
|
-
// 被踢后至少让 60s:对面可能是人手起的调试连接,立刻抢回去就是两边互踢
|
|
462
|
-
const wait = kickedAt && now() - kickedAt < 60_000 ? 60_000 - (now() - kickedAt) : 0
|
|
463
|
-
const go = () => {
|
|
464
|
-
if (stopping || myGen !== gen) return
|
|
465
|
-
// 看门狗因「卡在连接态过久」重建时,旧 socket 可能还半开着:先掐掉,免得它之后真连上成了第二条连接把新的踢掉
|
|
466
|
-
if (ws) { try { ws.terminate ? ws.terminate() : ws.close() } catch {} ws = null }
|
|
467
|
-
let sock
|
|
468
|
-
try { sock = new WebSocketImpl(wsUrl) } catch (e) { watchdog.onError(e); return }
|
|
469
|
-
ws = sock
|
|
470
|
-
sock.on('open', async () => {
|
|
471
|
-
if (myGen !== gen) return
|
|
472
|
-
const ack = await call('aibot_subscribe', { bot_id: botId, secret })
|
|
473
|
-
if (myGen !== gen) return
|
|
474
|
-
if (ack.errcode === 0) {
|
|
475
|
-
subscribed = true; lastAckAt = now(); releaseWaiters(true)
|
|
476
|
-
if (everReady) watchdog.onReconnected(); else { everReady = true; watchdog.onReady() }
|
|
477
|
-
log('企业微信长连接已订阅')
|
|
478
|
-
} else {
|
|
479
|
-
log(`企业微信订阅失败(${ack.errcode}): ${ack.errmsg || ''}`)
|
|
480
|
-
watchdog.onError(new Error(`subscribe ${ack.errcode} ${ack.errmsg || ''}`))
|
|
481
|
-
try { sock.terminate ? sock.terminate() : sock.close() } catch {}
|
|
482
|
-
}
|
|
483
|
-
})
|
|
484
|
-
sock.on('message', (buf) => { if (myGen === gen) handleFrame(buf) })
|
|
485
|
-
sock.on('close', (code, reason) => {
|
|
486
|
-
if (myGen !== gen) return
|
|
487
|
-
subscribed = false
|
|
488
|
-
if (stopping) return
|
|
489
|
-
log(`企业微信长连接断开(${code}${reason ? ' ' + String(reason) : ''})`)
|
|
490
|
-
watchdog.onError(new Error(`closed ${code}`))
|
|
491
|
-
})
|
|
492
|
-
sock.on('error', (e) => { if (myGen === gen) log('企业微信长连接错误: ' + (e && e.message)) })
|
|
493
|
-
}
|
|
494
|
-
if (wait > 0) setTimer(go, wait); else go()
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
function handleFrame(buf) {
|
|
498
|
-
let f
|
|
499
|
-
try { f = parseFrame(buf) } catch (e) { log('企业微信入站帧解析失败,已丢弃这一帧: ' + (e && e.message)); return }
|
|
500
|
-
if (!f || typeof f !== 'object') return
|
|
501
|
-
const id = f.headers && f.headers.req_id
|
|
502
|
-
if (!f.cmd) {
|
|
503
|
-
// 回包:ping 的只刷判活;其余按 req_id 交给等待者;没 req_id 的错误回包(超长 markdown 那种)只能记日志
|
|
504
|
-
if (id && /^ping_/.test(id)) { lastAckAt = now(); return }
|
|
505
|
-
const q = id && waiters.get(id)
|
|
506
|
-
if (q && q.length) { q.shift()(f); if (!q.length) waiters.delete(id); return }
|
|
507
|
-
if (f.errcode) log(`企业微信回包无人认领(${f.errcode}): ${f.errmsg || ''}`)
|
|
508
|
-
return
|
|
509
|
-
}
|
|
510
|
-
lastEventAt = now(); watchdog.onEvent()
|
|
511
|
-
if (f.cmd === 'aibot_msg_callback') {
|
|
512
|
-
const b = f.body || {}
|
|
513
|
-
const key = b.chattype === 'group' ? (b.chatid || '?') : ((b.from && b.from.userid) || '?')
|
|
514
|
-
const prev = inbound.get(key) || Promise.resolve()
|
|
515
|
-
const next = prev.then(() => onInbound(f)).catch((e) => log('企业微信入站处理异常: ' + (e && e.message)))
|
|
516
|
-
inbound.set(key, next)
|
|
517
|
-
next.then(() => { if (inbound.get(key) === next) inbound.delete(key) })
|
|
518
|
-
return
|
|
519
|
-
}
|
|
520
|
-
if (f.cmd === 'aibot_event_callback') { onEvent(f).catch((e) => log('企业微信事件处理异常: ' + (e && e.message))); return }
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
async function onInbound(f) {
|
|
524
|
-
const body = f.body || {}
|
|
525
|
-
learnName(body)
|
|
526
|
-
const norm = normalizeInbound(body, { botName: botNameOf() })
|
|
527
|
-
if (!norm) return
|
|
528
|
-
chatTypes.set(norm.chatId, norm.chatType === 'group' ? 2 : 1); atSeen.add(norm.chatId)
|
|
529
|
-
// 媒体到手就落盘(链接五分钟失效),标记进正文
|
|
530
|
-
const marks = []
|
|
531
|
-
for (let i = 0; i < norm.media.length; i++) {
|
|
532
|
-
const m = norm.media[i]
|
|
533
|
-
try {
|
|
534
|
-
const p = await saveMedia(m, `${norm.msgId}-${i + 1}`)
|
|
535
|
-
marks.push(mediaMark(m.kind, p))
|
|
536
|
-
} catch (e) { log(`企业微信媒体下载失败(${m.kind}): ` + (e && e.message)); marks.push(`[${m.kind === 'image' ? '图片' : '文件'} 下载失败]`) } // 原话只进日志(里面有路径 / 工具名,agent 会复述)
|
|
537
|
-
}
|
|
538
|
-
const text = [norm.text, ...marks].filter(Boolean).join('\n')
|
|
539
|
-
if (!text) return
|
|
540
|
-
// 轮询去重 / 跟进窗口:这条是长连接投递的 @ 消息
|
|
541
|
-
if (norm.chatType === 'group') {
|
|
542
|
-
const arr = recentInbound.get(norm.chatId) || []
|
|
543
|
-
arr.push({ text: norm.plain, t: norm.createTimeMs, userId: norm.userId }); if (arr.length > 30) arr.shift()
|
|
544
|
-
recentInbound.set(norm.chatId, arr)
|
|
545
|
-
if (poll) poll.noteActivity(norm.chatId)
|
|
546
|
-
}
|
|
547
|
-
// 姓名:手工映射 > 缓存 > **首次见到这个人就等一次搜人(最多 NAME_WAIT_MS)**。不能靠启动预热:群历史里的 userid 是工具
|
|
548
|
-
// 那套 woA2… 形态,与长连接给的明文 userid 对不上(10-10 真机),所以唯一能把明文换成姓名的就是按明文搜人。这里 await 不改变
|
|
549
|
-
// 消息顺序 —— 同一会话本来就串行(媒体下载也在等),只是第一条晚几秒;超时就先用账号,搜人结果照样进缓存给下一条。
|
|
550
|
-
// 回调 userid 是密文(woA2… 形态)= 机器人创建者不是超级管理员(官方 101463):搜人与手工映射都对不上,只能靠轮询历史桥接;提醒一次
|
|
551
|
-
if (!warnedEncUser && /^wo[A-Za-z0-9_-]{20,}$/.test(String(norm.userId))) { warnedEncUser = true; log('企业微信:回调里的成员账号是密文形态 —— 机器人创建者不是超级管理员;成员姓名要等群历史对上才显示,建议用超级管理员账号持有机器人') }
|
|
552
|
-
let senderName = names[norm.userId] || nameCache.get(norm.userId)
|
|
553
|
-
if (!senderName && !nameCache.has(norm.userId)) senderName = await warmName(norm.userId)
|
|
554
|
-
senderName = senderName || norm.userId
|
|
555
|
-
const reqId = f.headers && f.headers.req_id
|
|
556
|
-
const msg = {
|
|
557
|
-
chatId: norm.chatId,
|
|
558
|
-
userId: norm.userId,
|
|
559
|
-
senderName,
|
|
560
|
-
text,
|
|
561
|
-
msgId: norm.msgId,
|
|
562
|
-
createTimeMs: norm.createTimeMs,
|
|
563
|
-
replyCtx: { chatId: norm.chatId, chatType: norm.chatType, reqId, reqAt: now() },
|
|
564
|
-
}
|
|
565
|
-
if (!onMessage) return
|
|
566
|
-
Promise.resolve(onMessage(msg)).catch((e) => log('企业微信 onMessage 异常: ' + (e && e.message)))
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
async function onEvent(f) {
|
|
570
|
-
const body = f.body || {}
|
|
571
|
-
const ev = body.event || {}
|
|
572
|
-
const reqId = f.headers && f.headers.req_id
|
|
573
|
-
const userId = (body.from && body.from.userid) || 'unknown'
|
|
574
|
-
const chatId = body.chattype === 'group' ? body.chatid : userId
|
|
575
|
-
if (chatId) chatTypes.set(chatId, body.chattype === 'group' ? 2 : 1)
|
|
576
|
-
if (ev.eventtype === 'disconnected_event') {
|
|
577
|
-
const t = now(); kicks.push(t); while (kicks.length && t - kicks[0] > 10 * 60_000) kicks.shift()
|
|
578
|
-
if (kicks.length >= 2) {
|
|
579
|
-
halted = true
|
|
580
|
-
log(`企业微信:10 分钟内第二次被另一条连接踢下线 —— 停止重连(两台机器配了同一个 Bot ID?每台各建各的机器人);重启守护进程才会再连`)
|
|
581
|
-
onIssue({ issue: 'kicked', detail: `kicked ${kicks.length}x in 10min, botId …${String(botId).slice(-6)}` })
|
|
582
|
-
try { if (ws) (ws.terminate ? ws.terminate() : ws.close()) } catch {}
|
|
583
|
-
return
|
|
584
|
-
}
|
|
585
|
-
kickedAt = t
|
|
586
|
-
log('企业微信:另一条连接用同一个机器人登录,本连接被踢下线(60s 后再试一次;10 分钟内再被踢就停连)')
|
|
587
|
-
return
|
|
588
|
-
}
|
|
589
|
-
if (ev.eventtype === 'enter_chat') {
|
|
590
|
-
if (o.welcome) await call('aibot_respond_welcome_msg', { msgtype: 'markdown', markdown: { content: String(o.welcome) } }, reqId, 4_000)
|
|
591
|
-
return
|
|
592
|
-
}
|
|
593
|
-
if (ev.eventtype === 'template_card_event') {
|
|
594
|
-
const tc = ev.template_card_event || {}
|
|
595
|
-
if (!hooks.onCardAction) return
|
|
596
|
-
// 与飞书卡片回调同形:{ messageId, chatId, operator:{userId}, action:{value} };返回 {card} ⇒ 5s 内更新卡片
|
|
597
|
-
let r
|
|
598
|
-
try { r = await hooks.onCardAction({ messageId: body.msgid, chatId, operator: { userId }, action: { value: tc.event_key, taskId: tc.task_id, cardType: tc.card_type, selected: tc.selected_items } }) } catch (e) { log('卡片回调处理异常: ' + (e && e.message)); return }
|
|
599
|
-
if (r && r.card) {
|
|
600
|
-
const card = toTemplateCard(r.card, tc.task_id)
|
|
601
|
-
const ack = await call('aibot_respond_update_msg', { response_type: 'update_template_card', template_card: card }, reqId, 4_500)
|
|
602
|
-
if (ack.errcode) log(`卡片更新被拒(${ack.errcode}): ${ack.errmsg || ''}`)
|
|
603
|
-
}
|
|
604
|
-
return
|
|
605
|
-
}
|
|
606
|
-
if (ev.eventtype === 'feedback_event') { log(`企业微信反馈事件:${userId} type=${ev.feedback_event && ev.feedback_event.type}`); return }
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// 通用卡片形状 {title, desc, buttons:[{text,key,style}], taskId} ⇒ 企微 button_interaction 模板卡;
|
|
610
|
-
// 已经是企微形状(带 card_type)的原样放行。更新卡片时 task_id 必须沿用原卡的(换了 846625)。
|
|
611
|
-
function toTemplateCard(card, taskId) {
|
|
612
|
-
if (card && card.card_type) return taskId && !card.task_id ? { ...card, task_id: taskId } : card
|
|
613
|
-
const c = card || {}
|
|
614
|
-
const out = {
|
|
615
|
-
card_type: 'button_interaction',
|
|
616
|
-
main_title: { title: String(c.title || ' ').slice(0, 64), desc: String(c.desc || '').slice(0, 512) },
|
|
617
|
-
task_id: c.taskId || taskId || rid('task'),
|
|
618
|
-
}
|
|
619
|
-
const btns = Array.isArray(c.buttons) ? c.buttons.slice(0, 6) : []
|
|
620
|
-
out.button_list = btns.length ? btns.map((b, i) => ({ text: String(b.text || '确定').slice(0, 12), key: String(b.key || 'k' + i), style: Number(b.style) || 1 })) : [{ text: '知道了', key: 'ok', style: 4 }]
|
|
621
|
-
if (c.subTitle) out.sub_title_text = String(c.subTitle).slice(0, 512)
|
|
622
|
-
if (c.url) out.card_action = { type: 1, url: String(c.url) }
|
|
623
|
-
return out
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
async function saveMedia(m, base) {
|
|
627
|
-
const raw = await fetchBuffer(m.url, { maxBytes: MEDIA_MAX_BYTES, timeoutMs: 30_000 })
|
|
628
|
-
const data = m.aeskey ? decryptMedia(raw, m.aeskey) : raw
|
|
629
|
-
if (!data) throw new Error('解密失败')
|
|
630
|
-
fs.mkdirSync(inboxDir, { recursive: true, mode: 0o700 })
|
|
631
|
-
const safeBase = String(base).replace(/[^\w.-]+/g, '_').slice(0, 80)
|
|
632
|
-
const p = path.join(inboxDir, safeBase + sniffExt(data, m.name))
|
|
633
|
-
fs.writeFileSync(p, data, { mode: 0o600 })
|
|
634
|
-
return p
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// 轮询拿到的历史媒体(工具已落盘)⇒ 搬成收件目录里规范命名的文件:`<base><扩展名>`,扩展名先嗅探内容再看原文件名。
|
|
638
|
-
// 工具落的文件在 inboxDir 之外(它自己定的路径)就复制过来再删原件,在里面就原地改名;读不到原件直接抛(调用方记日志、给失败标记)。
|
|
639
|
-
function placePolledMedia(r, base, name) {
|
|
640
|
-
if (!r || !r.path) throw new Error('取媒体没有返回文件')
|
|
641
|
-
const data = fs.readFileSync(r.path)
|
|
642
|
-
fs.mkdirSync(inboxDir, { recursive: true, mode: 0o700 })
|
|
643
|
-
const safeBase = String(base).replace(/[^\w.-]+/g, '_').slice(0, 80)
|
|
644
|
-
const dest = path.join(inboxDir, safeBase + sniffExt(data, r.name || name))
|
|
645
|
-
if (path.resolve(r.path) !== dest) {
|
|
646
|
-
fs.writeFileSync(dest, data, { mode: 0o600 })
|
|
647
|
-
try { fs.unlinkSync(r.path) } catch { /* 原件删不掉不算失败,只是收件目录多一份 */ }
|
|
648
|
-
}
|
|
649
|
-
return dest
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// 姓名:官方 CLI `contact users search --keywords <userid>`(以机器人身份代授权人查;明文 userid 能搜中)。
|
|
653
|
-
// 没装 / 没授权 ⇒ 一次 ENOENT / 非零退出后不再试,群里就显示 userid。
|
|
654
|
-
// 返回 Promise<姓名|null>,最多等 NAME_WAIT_MS(走注入时钟,可测);超时先回 null,搜人仍在跑、结果照样进缓存。
|
|
655
|
-
function warmName(userId) {
|
|
656
|
-
if (cliDead) return Promise.resolve(null)
|
|
657
|
-
nameCache.set(userId, null) // 占位:同一人不并发多查
|
|
658
|
-
const lookup = new Promise((resolve) => {
|
|
659
|
-
exec(cli, ['contact', 'users', 'search', '--keywords', userId], { timeout: 8_000, maxBuffer: 1 << 20 }, (err, stdout) => {
|
|
660
|
-
if (err) {
|
|
661
|
-
if (err.code === 'ENOENT') { cliDead = true; log('企业微信命令行工具未安装,群里显示成员账号而非姓名') }
|
|
662
|
-
else log('企业微信搜人失败(' + userId + '): ' + String((err && err.message) || err).slice(0, 120))
|
|
663
|
-
// 失败也留 null 占位:同一个人不每条消息都再起一次子进程(未授权 / 超时这种状况一会儿不会变)
|
|
664
|
-
nameCache.set(userId, null)
|
|
665
|
-
return resolve(null)
|
|
666
|
-
}
|
|
667
|
-
try {
|
|
668
|
-
const j = JSON.parse(String(stdout || '{}'))
|
|
669
|
-
// 只认精确命中(userid 相等或关键词命中),模糊到别人头上就是张冠李戴
|
|
670
|
-
const u = (j.users || []).find((x) => x && (x.userid === userId || (x.matched_keywords || []).includes(userId)))
|
|
671
|
-
nameCache.set(userId, (u && u.name) || null)
|
|
672
|
-
resolve((u && u.name) || null)
|
|
673
|
-
} catch { nameCache.set(userId, null); resolve(null) }
|
|
674
|
-
})
|
|
675
|
-
})
|
|
676
|
-
let t = null
|
|
677
|
-
const deadline = new Promise((resolve) => { t = setTimer(() => resolve(null), NAME_WAIT_MS) })
|
|
678
|
-
return Promise.race([lookup, deadline]).then((v) => { if (t) clearTimer(t); return v })
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// 心跳 + 判活(一处):每 30s 发 ping;连续 75s 没收到任何 ping 回包就主动掐线 —— 掐线触发 close ⇒ 看门狗退避重建。
|
|
682
|
-
// 不能等 close 事件:真机上服务端静默停投时 TCP 一直 ESTABLISHED,永远不会有 close。
|
|
683
|
-
function pingTick() {
|
|
684
|
-
if (!ws || ws.readyState !== 1 || !subscribed) return
|
|
685
|
-
try { ws.send(JSON.stringify({ cmd: 'ping', headers: { req_id: rid('ping') } })) } catch {}
|
|
686
|
-
if (lastAckAt && now() - lastAckAt > STALE_MS) {
|
|
687
|
-
log(`企业微信长连接 ${Math.round((now() - lastAckAt) / 1000)}s 没有心跳回包,主动掐线重连`)
|
|
688
|
-
try { ws.terminate ? ws.terminate() : ws.close() } catch {}
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
// 主动推送要带 chat_type:入站时按 chatId 记过的优先;没见过的按形态猜 —— 群会话 id 以 wr 开头(真机实证),其余当 userid
|
|
693
|
-
const chatTypeOf = (chatId) => chatTypes.get(chatId) || (/^wr/.test(String(chatId)) ? 2 : 1)
|
|
694
|
-
|
|
695
|
-
// 两条出站路:respond = 对某条回调的回复(透传 req_id,排队 / 进度 / 正文都走它);send = 主动推送(合成消息、定时任务、告警)。
|
|
696
|
-
// 都按 errcode 判,被拒返回 null —— 这是 Platform 契约里「确认没送到」的信号,调用方据此重试或记账。
|
|
697
|
-
async function sendMarkdown(chatId, content) {
|
|
698
|
-
if (!subscribed && !(await whenSubscribed(SEND_WAIT_MS))) { log(`企业微信发送放弃:等了 ${SEND_WAIT_MS / 1000}s 长连接仍未订阅成功`); return null }
|
|
699
|
-
const ack = await call('aibot_send_msg', { chatid: chatId, chat_type: chatTypeOf(chatId), msgtype: 'markdown', markdown: { content } })
|
|
700
|
-
if (ack.errcode) { log(`企业微信发送被拒(${ack.errcode}): ${ack.errmsg || ''}${pushRejectHint(ack.errcode, chatId)}`); return null }
|
|
701
|
-
return 'ok'
|
|
702
|
-
}
|
|
703
|
-
// 846607 一个码两种人话(官方调研第 5 条):本进程收到过该会话的 @ ⇒ 必是超频(每会话 30 条 / 分、1000 条 / 时);
|
|
704
|
-
// 没收到过 ⇒ 多半是「还没人 @ 过机器人」(主动推送的前提),但「有人 @ 过」是服务端跨重启的事实而 atSeen 重启即空 ——
|
|
705
|
-
// 刚重启后的第一条推送(升级播报)若被拒,也可能是上一进程刚刷过的超频,文案要留这个口子。
|
|
706
|
-
const pushRejectHint = (code, chatId) => code !== 846607 ? '' : (atSeen.has(chatId) ? ' —— 本会话推送超频(每分钟 30 条 / 每小时 1000 条),稍后再发' : ' —— 本次启动后还没收到过这个会话的消息:多半是还没人 @ 过机器人(先 @ 一次);若刚重启且此前聊过,则是推送超频')
|
|
707
|
-
async function respondMarkdown(reqId, content) {
|
|
708
|
-
const ack = await call('aibot_respond_msg', { msgtype: 'markdown', markdown: { content } }, reqId)
|
|
709
|
-
if (ack.errcode) { log(`企业微信回复被拒(${ack.errcode}): ${ack.errmsg || ''}`); return null }
|
|
710
|
-
return 'ok'
|
|
711
|
-
}
|
|
712
|
-
// 回调 req_id 还在可回复窗口内才走回复路,否则走主动推送(合成消息 / 定时任务本来就没有 req_id)
|
|
713
|
-
const canRespond = (ctx) => !!(ctx && ctx.reqId && (!ctx.reqAt || now() - ctx.reqAt < RESPOND_WINDOW_MS))
|
|
714
|
-
|
|
715
|
-
// 自描述:引擎据此改写自知清单(命令名 / 没有的能力不教)。core 不认识「企微」,只认这些字段。
|
|
716
|
-
const platform = {
|
|
717
|
-
skillCmd: 'sbot wecom',
|
|
718
|
-
chatLabel: '企业微信群',
|
|
719
|
-
// history 有(走官方 CLI 拉近 7 天群历史);回复 / 编辑 / 撤回 / 表情 / 下载 / 画布 / 生图没有;回复是普通消息不是钉住的卡片;send-msg 渲染 markdown
|
|
720
|
-
// imagen / takeoverCard 翻回 true(P2):生图完成通知与接管入口都已按平台分流(走官方命令行工具,见 bin 的 imagenWorker / cmdBrowser)
|
|
721
|
-
features: { editRecall: false, react: false, download: false, boards: false, imagen: true, takeoverCard: true, cardPinned: false, plainSend: false, mediaMarker: 'path' },
|
|
722
|
-
|
|
723
|
-
async start(cb, h = {}) {
|
|
724
|
-
onMessage = cb
|
|
725
|
-
hooks = h || {}
|
|
726
|
-
stopping = false
|
|
727
|
-
connect()
|
|
728
|
-
pingTimer = setInterval(pingTick, PING_MS)
|
|
729
|
-
if (pingTimer.unref) pingTimer.unref()
|
|
730
|
-
tickTimer = setInterval(() => watchdog.tick(), TICK_MS)
|
|
731
|
-
if (tickTimer.unref) tickTimer.unref()
|
|
732
|
-
log('企业微信长连接已启动(心跳 30s · 75s 无回包重连)')
|
|
733
|
-
// 「不 @ 也回复」:有命令行工具且 pollSec > 0 才起;群列表每 tick 现读(跟随热重载)
|
|
734
|
-
if (cli && pollSec > 0 && typeof o.pollChats === 'function') {
|
|
735
|
-
const wcli = createWecomCli({ bin: cli, exec })
|
|
736
|
-
poll = createHistoryPoll({
|
|
737
|
-
chats: o.pollChats,
|
|
738
|
-
// 850003 = 工具的能力授权过期(每 7 天一续,auth show 看不出):进告警群让创建者去点续期链接;其余错误照常只记日志
|
|
739
|
-
// 853006 = 企业规模门控(官方未对本企业开放读群历史,不是配置错):同样进告警群说清;轮询侧自己会暂停半小时再试
|
|
740
|
-
list: (chatId) => wcli.history(chatId).catch((e) => {
|
|
741
|
-
const code = e && String(e.code)
|
|
742
|
-
if ((code === '850003' || code === '853006') && now() - warnedAuth > 10 * 60_000) { warnedAuth = now(); onIssue({ issue: code === '850003' ? 'auth-expired' : 'tool-gated', detail: e.message }) }
|
|
743
|
-
throw e
|
|
744
|
-
}),
|
|
745
|
-
// 历史里的图片 / 文件:工具按 media_id 取内容落到收件目录,再按内容嗅探扩展名、改成与长连接入站同款的文件名
|
|
746
|
-
// (工具落盘的文件名是固定的 msg_media 之类,没有扩展名,agent 用看图工具打开会认不出格式)
|
|
747
|
-
fetchMedia: async (md, base) => placePolledMedia(await wcli.fetchMedia(md.mediaId, inboxDir), base, md.name),
|
|
748
|
-
mark: mediaMark,
|
|
749
|
-
gate: typeof o.gate === 'function' ? o.gate : null, // 第二道门(便宜模型门控),bin 按 wecom.gatekeeper 配置建;没配 = 只有规则那道门
|
|
750
|
-
deliver: (m) => { chatTypes.set(m.chatId, 2); if (onMessage) Promise.resolve(onMessage(m)).catch((e) => log('企业微信 onMessage 异常: ' + (e && e.message))) },
|
|
751
|
-
// 去重命中 = 同一条消息在两边都见到了 ⇒ 顺手把「长连接的明文账号 ↔ 历史里的姓名」记进姓名缓存(两边 userid 不是一个空间,
|
|
752
|
-
// 只有这一刻能对上;零额外调用)。手工 names 仍最高优先;下一条该成员的消息就有姓名了
|
|
753
|
-
wasDelivered: (chatId, text, tMs, meta) => {
|
|
754
|
-
const k = String(text || '').replace(/\s+/g, ' ').trim()
|
|
755
|
-
const hit = (recentInbound.get(chatId) || []).find((r) => r.text === k && Math.abs(r.t - tMs) <= DUP_WINDOW_MS)
|
|
756
|
-
if (hit && meta && meta.userName && hit.userId && nameCache.get(hit.userId) !== meta.userName) { nameCache.set(hit.userId, meta.userName); log(`企业微信:从群历史对上成员姓名 ${hit.userId} → ${meta.userName}`) }
|
|
757
|
-
return !!hit
|
|
758
|
-
},
|
|
759
|
-
stripMention: (t) => stripMention(t, botNameOf()),
|
|
760
|
-
now, setTimer, clearTimer, intervalMs: pollSec * 1000, followMs: followSec * 1000,
|
|
761
|
-
botName: botNameOf, keywords: keywordsOf, log,
|
|
762
|
-
})
|
|
763
|
-
poll.start()
|
|
764
|
-
log(`企业微信「不 @ 也回复」已开:群历史按档拉 —— 刚聊过 8s / 平时 ${pollSec}s / ${Math.round(Math.max(6 * pollSec, 120))}s 没人说话时 ${Math.round(Math.max(6 * pollSec, 120))}s;${followSec > 0 ? `跟进窗口 ${followSec}s` : '不设跟进窗口(全部都喂)'};@ 消息走长连接即时`)
|
|
765
|
-
}
|
|
766
|
-
},
|
|
767
|
-
|
|
768
|
-
connHealth() {
|
|
769
|
-
return { ...watchdog.snapshot(), lastAckAt, lastEventAt, subscribed, pingTimeoutSec: Math.round(STALE_MS / 1000), botName: botNameOf(), botNameLearned: learnedName, halted, encryptedUserIds: warnedEncUser }
|
|
770
|
-
},
|
|
771
|
-
|
|
772
|
-
async reply(replyCtx, content, opts) {
|
|
773
|
-
// 轮询来的消息走主动推送,推送不会像回复那样自动引用原话 ⇒ 群里分不清机器人在回谁。**最终回答**那次推送开头加一行引用
|
|
774
|
-
// 「回复 某某:原话前 40 字」(v0.11.113 的引用行样式);只认引擎标了 final 的那次 —— 排队 / 切换模型 / 重试这类系统短句不配引用;
|
|
775
|
-
// 同一条消息只引一次。
|
|
776
|
-
let body = content
|
|
777
|
-
if (replyCtx && replyCtx.quote && opts && opts.final && !canRespond(replyCtx) && !replyCtx._quoted) {
|
|
778
|
-
replyCtx._quoted = true
|
|
779
|
-
const q = replyCtx.quote
|
|
780
|
-
const who = (String(q.sender || '').trim() || '群友') + (q.senders > 1 ? ` 等 ${q.senders} 人` : '')
|
|
781
|
-
const more = q.more > 0 ? `(连同前 ${q.more} 条)` : ''
|
|
782
|
-
body = `> 回复 ${who}:${String(q.text || '').replace(/\s+/g, ' ').trim()}${more}\n\n${content}`
|
|
783
|
-
}
|
|
784
|
-
const chunks = splitMarkdown(mdForWecom(body))
|
|
785
|
-
if (!chunks.length) return null
|
|
786
|
-
if (poll && replyCtx && replyCtx.chatId) poll.noteActivity(replyCtx.chatId) // 机器人开口 ⇒ 续跟进窗口 + 热档
|
|
787
|
-
let last = null
|
|
788
|
-
for (const c of chunks) last = canRespond(replyCtx) ? await respondMarkdown(replyCtx.reqId, c) : await sendMarkdown(replyCtx && replyCtx.chatId, c)
|
|
789
|
-
if (poll && replyCtx && replyCtx.chatId) poll.kick(replyCtx.chatId, AFTER_REPLY_POLL_MS) // 答完几秒就补拉一次:跟进句通常紧跟在回答后
|
|
790
|
-
return last
|
|
791
|
-
},
|
|
792
|
-
|
|
793
|
-
async send(chatId, content) {
|
|
794
|
-
const chunks = splitMarkdown(mdForWecom(content))
|
|
795
|
-
if (!chunks.length || !chatId) return null
|
|
796
|
-
if (poll) poll.noteActivity(chatId)
|
|
797
|
-
let last = null
|
|
798
|
-
for (const c of chunks) last = await sendMarkdown(chatId, c)
|
|
799
|
-
if (poll) poll.kick(chatId, AFTER_REPLY_POLL_MS)
|
|
800
|
-
return last
|
|
801
|
-
},
|
|
802
|
-
|
|
803
|
-
// 发一张按钮卡(通用形状见 toTemplateCard);返回 { msgId:null, taskId } —— 企微不回消息 id,按钮事件靠 task_id 对号
|
|
804
|
-
async sendCard(chatId, card) {
|
|
805
|
-
const tc = toTemplateCard(card)
|
|
806
|
-
if (!subscribed && !(await whenSubscribed(SEND_WAIT_MS))) { log(`企业微信发卡放弃:等了 ${SEND_WAIT_MS / 1000}s 长连接仍未订阅成功`); return null }
|
|
807
|
-
const ack = await call('aibot_send_msg', { chatid: chatId, chat_type: chatTypeOf(chatId), msgtype: 'template_card', template_card: tc })
|
|
808
|
-
if (ack.errcode) { log(`企业微信发卡被拒(${ack.errcode}): ${ack.errmsg || ''}`); return null }
|
|
809
|
-
return { msgId: null, cardId: null, taskId: tc.task_id }
|
|
810
|
-
},
|
|
811
|
-
|
|
812
|
-
// 流式回复:只有带 req_id 的真实消息才有(合成消息 / 定时任务退回普通回复)
|
|
813
|
-
async createStreamingCard(replyCtx) {
|
|
814
|
-
if (!canRespond(replyCtx)) return null
|
|
815
|
-
const reqId = replyCtx.reqId
|
|
816
|
-
const sess = createStreamSession({
|
|
817
|
-
send: (body) => call('aibot_respond_msg', body, reqId),
|
|
818
|
-
sendMarkdown: (c) => respondMarkdown(reqId, c),
|
|
819
|
-
now, setTimer, clearTimer, throttleMs, log,
|
|
820
|
-
})
|
|
821
|
-
// 流式收尾 = 机器人答完 ⇒ 几秒后补拉一次该群历史(@ 之后的跟进句不用等到下一档)
|
|
822
|
-
const fin = sess.finish
|
|
823
|
-
sess.finish = async (...a) => { const r = await fin(...a); if (poll && replyCtx.chatId) poll.kick(replyCtx.chatId, AFTER_REPLY_POLL_MS); return r }
|
|
824
|
-
return sess
|
|
825
|
-
},
|
|
826
|
-
|
|
827
|
-
async stop() {
|
|
828
|
-
stopping = true
|
|
829
|
-
releaseWaiters(false) // 关停时排队的发送别再等了
|
|
830
|
-
if (poll) { try { poll.stop() } catch {} poll = null }
|
|
831
|
-
if (pingTimer) { clearInterval(pingTimer); pingTimer = null }
|
|
832
|
-
if (tickTimer) { clearInterval(tickTimer); tickTimer = null }
|
|
833
|
-
try { watchdog.stop() } catch {}
|
|
834
|
-
gen++
|
|
835
|
-
try { if (ws) (ws.terminate ? ws.terminate() : ws.close()) } catch {}
|
|
836
|
-
ws = null
|
|
837
|
-
},
|
|
838
|
-
|
|
839
|
-
// 测试钩子
|
|
840
|
-
_internal: { call, chatTypes, nameCache, handleFrame, normalizeInbound, pingTick, poll: () => poll, placePolledMedia },
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
return platform
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
// 控制台「绑定群」的候选来源:企微没有「列出机器人所在的群」接口,能拿到的只有**机器人被 @ 过的会话**
|
|
847
|
-
// (官方 CLI `message aibot sessions list` 最近 20 个 + `chat groups list` 近 7 天有消息的群,两路合并去重)。
|
|
848
|
-
// 所以新群要先在群里 @ 一次机器人才会出现在候选里 —— 控制台文案要把这一步说清。只返回群,单聊不绑项目。
|
|
849
|
-
function listWecomSessions({ cli = 'wecom-cli', exec = execFile, now = () => new Date() } = {}) {
|
|
850
|
-
const run = (args) => new Promise((resolve, reject) => {
|
|
851
|
-
exec(cli, args, { timeout: 20_000, maxBuffer: 4 << 20 }, (err, stdout, stderr) => {
|
|
852
|
-
if (err && err.code === 'ENOENT') return reject(new Error('企业微信命令行工具未安装 —— 装好并用机器人凭据授权一次后,这里才能列出群'))
|
|
853
|
-
let j = null
|
|
854
|
-
try { j = JSON.parse(String(stdout || '').trim() || 'null') } catch { /* 下面按失败处理 */ }
|
|
855
|
-
if (j && j.error) return reject(new Error(j.error.message || String(j.error.code || '工具报错')))
|
|
856
|
-
if (!j) return reject(new Error(err ? String((stderr || err.message || '')).trim().slice(0, 200) || '工具没有返回结果' : '工具没有返回可读的结果'))
|
|
857
|
-
resolve(j)
|
|
858
|
-
})
|
|
859
|
-
})
|
|
860
|
-
const fmt = fmtBeijing // 时间字面量按北京(服务端口径),与主机时区无关
|
|
861
|
-
return Promise.all([
|
|
862
|
-
run(['message', 'aibot', 'sessions', 'list']).catch((e) => { throw e }),
|
|
863
|
-
(() => { const end = now(); const begin = new Date(end.getTime() - HISTORY_DAYS * 86400_000); return run(['chat', 'groups', 'list', '--begin-time', fmt(begin), '--end-time', fmt(end)]).catch(() => ({ chats: [] })) })(), // 第二路拿不到不致命;整 7 天会被拒
|
|
864
|
-
]).then(([ses, grp]) => {
|
|
865
|
-
const out = new Map()
|
|
866
|
-
for (const s of (ses.sessions || [])) if (s && s.chat_type === 'group' && s.chat_id) out.set(s.chat_id, { chatId: s.chat_id, name: s.chat_name || s.chat_id, lastMsgTime: s.last_msg_time || '' })
|
|
867
|
-
for (const c of (grp.chats || [])) if (c && c.chat_id && !out.has(c.chat_id)) out.set(c.chat_id, { chatId: c.chat_id, name: c.chat_name || c.chat_id, lastMsgTime: c.last_msg_time || '' })
|
|
868
|
-
return [...out.values()].sort((a, b) => String(b.lastMsgTime).localeCompare(String(a.lastMsgTime)))
|
|
869
|
-
})
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// 官方命令行工具当前授权给了哪个机器人:`auth show` 输出是两行纯文本「Status: authorized」「Bot ID: aib…」(不是 JSON)。
|
|
873
|
-
// 为什么要查:工具的授权与 daemon 的凭据是两套 —— 授权成另一个机器人时,搜姓名照样能搜到(通讯录是企业级的),但助手发图 /
|
|
874
|
-
// 发文件会从**那个**机器人发出、列出的「被 @ 过的群」也是那个机器人的,现象是「图发了群里没看到」。预检要把这事说出来。
|
|
875
|
-
function wecomAuthShow({ cli = 'wecom-cli', exec = execFile } = {}) {
|
|
876
|
-
return new Promise((resolve, reject) => {
|
|
877
|
-
exec(cli, ['auth', 'show'], { timeout: 15_000, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
|
|
878
|
-
if (err && err.code === 'ENOENT') return reject(Object.assign(new Error('企业微信命令行工具未安装'), { code: 'ENOENT' }))
|
|
879
|
-
const text = String(stdout || '') + '\n' + String(stderr || '')
|
|
880
|
-
const st = /Status:\s*(\S+)/i.exec(text), id = /Bot ID:\s*(\S+)/i.exec(text)
|
|
881
|
-
if (!st && !id) return reject(new Error(err ? String(stderr || err.message || '').trim().slice(0, 200) || '工具没有返回授权状态' : '工具没有返回可读的授权状态'))
|
|
882
|
-
resolve({ authorized: !!st && /^authorized$/i.test(st[1]), botId: id ? id[1] : null })
|
|
883
|
-
})
|
|
884
|
-
})
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
// 入站媒体下载:链接五分钟失效、内容加密,只能到手就取;放内存是因为解密要整段做(CBC + 整体填充),且上限 100MB 可控
|
|
888
|
-
function defaultFetchBuffer(url, { maxBytes = MEDIA_MAX_BYTES, timeoutMs = 30_000, hops = 0 } = {}) {
|
|
889
|
-
return new Promise((resolve, reject) => {
|
|
890
|
-
const mod = /^https:/i.test(url) ? https : http
|
|
891
|
-
const req = mod.get(url, (res) => {
|
|
892
|
-
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && hops < 2) {
|
|
893
|
-
res.resume()
|
|
894
|
-
return defaultFetchBuffer(new URL(res.headers.location, url).toString(), { maxBytes, timeoutMs, hops: hops + 1 }).then(resolve, reject)
|
|
895
|
-
}
|
|
896
|
-
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)) }
|
|
897
|
-
const chunks = []; let size = 0
|
|
898
|
-
res.on('data', (c) => { size += c.length; if (size > maxBytes) { req.destroy(new Error('too large')); return } chunks.push(c) })
|
|
899
|
-
res.on('end', () => resolve(Buffer.concat(chunks)))
|
|
900
|
-
res.on('error', reject)
|
|
901
|
-
})
|
|
902
|
-
req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout'))) // 空闲超时
|
|
903
|
-
const hard = setTimeout(() => req.destroy(new Error('timeout')), timeoutMs * 2) // 总时长硬上限:慢速滴流不能无限拖(同会话后面的消息都在等它)
|
|
904
|
-
req.on('close', () => clearTimeout(hard))
|
|
905
|
-
req.on('error', reject)
|
|
906
|
-
})
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
// 配置里的 ~ / ~/ 展开成家目录(只认开头的 ~)
|
|
910
|
-
function expandHome(p) {
|
|
911
|
-
if (!p || typeof p !== 'string') return null
|
|
912
|
-
if (p === '~') return os.homedir()
|
|
913
|
-
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2))
|
|
914
|
-
return p
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
module.exports = {
|
|
918
|
-
createWecomPlatform,
|
|
919
|
-
createStreamSession,
|
|
920
|
-
listWecomSessions,
|
|
921
|
-
wecomAuthShow,
|
|
922
|
-
normalizeInbound,
|
|
923
|
-
stripMention,
|
|
924
|
-
parseFrame,
|
|
925
|
-
mdForWecom,
|
|
926
|
-
splitMarkdown,
|
|
927
|
-
decryptMedia,
|
|
928
|
-
encryptMedia,
|
|
929
|
-
decodeAesKey,
|
|
930
|
-
expandHome,
|
|
931
|
-
sniffExt,
|
|
932
|
-
mediaMark,
|
|
933
|
-
plainKey,
|
|
934
|
-
MD_MAX_BYTES,
|
|
935
|
-
MD_SAFE_BYTES,
|
|
936
|
-
STREAM_ROLL_MS,
|
|
937
|
-
WS_URL,
|
|
938
|
-
}
|