sintbot 1.0.16 → 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 +99 -0
- package/CHANGELOG.json +99 -0
- package/README.md +20 -158
- package/README.zh-CN.md +20 -166
- package/THIRD-PARTY-NOTICES.md +394 -0
- package/bin/sintbot-v2.js +363 -0
- package/docs/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/install.md +61 -167
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/zh-CN/install.md +61 -165
- package/install.sh +84 -0
- package/package.json +73 -37
- 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 -4716
- 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/cli-secret.js +0 -36
- 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 -2758
- 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 -5526
- 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/wecomcli.js
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
// 企业微信官方命令行工具的薄封装:以机器人身份 HTTP 直连发消息 / 传素材 / 拉历史 / 搜人。
|
|
2
|
-
//
|
|
3
|
-
// 为什么走它而不是 daemon 的长连接:同一个机器人只许一条长连接,子进程(基础技能 `sbot wecom`、生图 worker、
|
|
4
|
-
// 接管链接 `--send`)再开一条就把 daemon 踢下线;工具是 HTTP 直连,不占那条连接。三处调用方此前各写一份
|
|
5
|
-
// exec 包装(cmdWecom 里那份还有个 `u.userid` 的未定义变量,单聊发送一跑就炸)⇒ 收成一处。
|
|
6
|
-
//
|
|
7
|
-
// ⚠ 两套 id 空间:daemon 从长连接拿到的单聊 chatId 是明文 userid,工具只认它自己那套(woA2… 形态);
|
|
8
|
-
// 群 id(wr 开头)两边一致。所以不是群的目标先搜人换算,且只认精确且唯一的命中 —— 模糊命中会发到别人那里。
|
|
9
|
-
const { execFile } = require('node:child_process')
|
|
10
|
-
const fs = require('node:fs')
|
|
11
|
-
const path = require('node:path')
|
|
12
|
-
|
|
13
|
-
// 群历史只能查近 7 天,而 begin_time 恰好 7 天前会被拒(850016,真机踩到:主机钟与服务端钟差几秒就越界)⇒ 一律取 6 天
|
|
14
|
-
const HISTORY_DAYS = 6
|
|
15
|
-
|
|
16
|
-
// 工具要的「YYYY-MM-DD HH:MM:SS」字面量按**北京时间**解释(服务端在国内),与主机时区无关 —— 生产机是美东,按本机
|
|
17
|
-
// 格式化等于把窗口整体错开 12 小时:当天的消息全查不到、还一声不响(10-10 真机踩到,此前文档写的「本机时区」是错的)
|
|
18
|
-
function fmtBeijing(d) {
|
|
19
|
-
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).formatToParts(d).map((x) => [x.type, x.value]))
|
|
20
|
-
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function createWecomCli({ bin = 'wecom-cli', exec = execFile, timeoutMs = 60_000 } = {}) {
|
|
24
|
-
// 调工具:JSON 回包里固定带两段安全提示字段,剥掉;{error:{code,message}} ⇒ 抛;退出码 0 但不是 JSON 也不能当成功
|
|
25
|
-
// opts.cwd:工具只许把下载的文件写进**它自己的 cwd**(或 /private/tmp)—— 真机 10-12:`--output-dir ~/.sbot/wecom-inbox` 被拒
|
|
26
|
-
// 「目标路径超出可访问范围(允许范围: <cwd>, /private/tmp)」。所以取媒体时把 cwd 设成目标目录、`--output-dir .`
|
|
27
|
-
const run = (args, opts = {}) => new Promise((resolve, reject) => {
|
|
28
|
-
exec(bin, args, { timeout: timeoutMs, maxBuffer: 8 << 20, ...(opts.cwd ? { cwd: opts.cwd } : {}) }, (err, stdout, stderr) => {
|
|
29
|
-
if (err && err.code === 'ENOENT') return reject(Object.assign(new Error('企业微信命令行工具未安装(doctor 的 wecom.cli 行)'), { code: 'ENOENT' }))
|
|
30
|
-
let j = null
|
|
31
|
-
try { j = JSON.parse(String(stdout || '').trim() || '{}') } catch { /* 非 JSON 输出按下面处理 */ }
|
|
32
|
-
if (j && j.error) {
|
|
33
|
-
// 两个要翻成人话的码(10-12 调研):850003 = 工具的能力授权(每 7 天一续)过期,要创建者点续期链接;853006 = 企业规模门控
|
|
34
|
-
// (>10 人企业官方尚未开放 chat.* 与 message send,不是配置错)。原码与续期链接挂在 Error 上给日志 / 告警用
|
|
35
|
-
const code = j.error.code, help = j.error.help_message || j.help_message || ''
|
|
36
|
-
const msg = String(code) === '850003' ? '工具的能力授权已过期(每 7 天一续),请机器人创建者点续期链接' + (help ? ':' + String(help).slice(0, 200) : '')
|
|
37
|
-
: String(code) === '853006' ? '企业规模门控:企业超过 10 人时官方尚未开放这项能力(不是配置错)'
|
|
38
|
-
: `${j.error.message || code || '失败'}`
|
|
39
|
-
return reject(Object.assign(new Error(msg), { code, help }))
|
|
40
|
-
}
|
|
41
|
-
if (err && !j) return reject(new Error(String(stderr || err.message || '').trim().slice(0, 300)))
|
|
42
|
-
if (!j) return reject(new Error('工具没有返回可读的结果:' + String(stdout || stderr || '').trim().slice(0, 200)))
|
|
43
|
-
if (!opts.keepContext) { delete j.security_notice; delete j.extra_identity_context }
|
|
44
|
-
resolve(j)
|
|
45
|
-
})
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
// 搜人:只认精确命中(userid 相等或关键词命中),模糊到别人头上就是张冠李戴
|
|
49
|
-
const findUser = async (userId) => {
|
|
50
|
-
const r = await run(['contact', 'users', 'search', '--keywords', userId])
|
|
51
|
-
return (r.users || []).filter((x) => x && (x.userid === userId || (x.matched_keywords || []).includes(userId)))
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// 目标会话换算成工具认的 id:群原样;单聊搜人,精确且唯一才发
|
|
55
|
-
const chatIdForCli = async (chatId) => {
|
|
56
|
-
if (/^wr/.test(chatId)) return chatId
|
|
57
|
-
const hits = await findUser(chatId)
|
|
58
|
-
if (hits.length !== 1 || !hits[0].userid) throw new Error(hits.length ? '单聊对象不唯一,发不了' : '找不到这个单聊对象的账号,发不了')
|
|
59
|
-
return hits[0].userid
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// 工具的 send 子命令按 --msg-type 取同名 JSON 参数(--markdown / --image / --file);success:false 也算失败
|
|
63
|
-
const sendBody = async (chatId, msgType, payload) => {
|
|
64
|
-
const r = await run(['message', 'aibot', 'send', '--chat-id', await chatIdForCli(chatId), '--msg-type', msgType, '--' + msgType, JSON.stringify(payload)])
|
|
65
|
-
if (r.success === false) throw new Error(r.message || '发送被拒')
|
|
66
|
-
return r
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const upload = async (kind, absPath) => {
|
|
70
|
-
const up = await run(['media', 'upload', '--type', kind, '--file-path', absPath])
|
|
71
|
-
if (!up.media_id) throw new Error('上传没有返回素材 id')
|
|
72
|
-
return up.media_id
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// 机器人自己是谁:官方工具的隐藏服务 `identity whoami`(--help 不列,schema list 能看到;10-12 调研 + 真机)—— 平台所有
|
|
77
|
-
// 其他地方(长连接协议 / 其余接口)都没有机器人名,这是唯一的正路。回包是一段文本:
|
|
78
|
-
// 机器人身份:\n名字:SintBot\nID:aib…\n授权真人用户身份:\n名字:张三\nID:wo…
|
|
79
|
-
// 解析成 { bot:{name,id}, user:{name,id} };老版工具没这条服务 ⇒ 抛(调用方退回扫群历史里的 @)
|
|
80
|
-
const whoami = async () => {
|
|
81
|
-
const j = await run(['identity', 'whoami'], { keepContext: true })
|
|
82
|
-
const t = String(j.extra_identity_context || '')
|
|
83
|
-
const pick = (head) => { const m = new RegExp(head + '[::]\\s*名字[::]\\s*([^\\n]*?)\\s*\\n\\s*ID[::]\\s*(\\S+)').exec(t); return m ? { name: m[1].trim(), id: m[2].trim() } : null }
|
|
84
|
-
const bot = pick('机器人身份'), user = pick('授权真人用户身份')
|
|
85
|
-
if (!bot || !bot.name) throw new Error('身份接口没有返回机器人名')
|
|
86
|
-
return { bot, user }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return {
|
|
90
|
-
run,
|
|
91
|
-
whoami,
|
|
92
|
-
findUser,
|
|
93
|
-
chatIdForCli,
|
|
94
|
-
sendMarkdown: (chatId, content) => sendBody(chatId, 'markdown', { content }),
|
|
95
|
-
sendImage: async (chatId, absPath) => sendBody(chatId, 'image', { media_id: await upload('image', absPath) }),
|
|
96
|
-
sendFile: async (chatId, absPath) => sendBody(chatId, 'file', { media_id: await upload('file', absPath) }),
|
|
97
|
-
// 群历史(近 N 天,只有群;真机上含不 @ 机器人的人类发言、带 userid 与 user_name,不含机器人自己发的)
|
|
98
|
-
history: async (chatId, { days = HISTORY_DAYS, now = () => new Date() } = {}) => {
|
|
99
|
-
const end = now(), begin = new Date(end.getTime() - days * 86400_000)
|
|
100
|
-
const r = await run(['chat', 'messages', 'list', '--chat-id', chatId, '--begin-time', fmtBeijing(begin), '--end-time', fmtBeijing(end)])
|
|
101
|
-
return r.messages || []
|
|
102
|
-
},
|
|
103
|
-
// 取群历史里某条消息的媒体(图片 / 文件 / 视频):历史列表只给 media_id 不给内容,要再调一次这个接口。
|
|
104
|
-
// 工具的回包有两种形态(schema 写明):内容长或含非 UTF-8 字节 ⇒ 框架落盘到 --output-dir,JSON 里给 file_path;
|
|
105
|
-
// 内容短 ⇒ 直接给 content 字串。两种都归一成 { path, name, type }:文件一定在本机磁盘上,调用方只管搬与改名。
|
|
106
|
-
// 真机 10-12:落盘形态 = `file_path` 绝对路径 + 原始文件名(`企业微信截图_….png`,带扩展名)+ 0600;相对路径也兜(按 outDir 解析)。
|
|
107
|
-
fetchMedia: async (mediaId, outDir) => {
|
|
108
|
-
if (!mediaId) throw new Error('缺少媒体 id')
|
|
109
|
-
fs.mkdirSync(outDir, { recursive: true, mode: 0o700 }) // cwd 必须先存在,否则 spawn 直接 ENOENT
|
|
110
|
-
const r = await run(['message', 'files', 'get', '--media-id', String(mediaId), '--output-dir', '.'], { cwd: outDir })
|
|
111
|
-
const it = (r && r.media_item) || {}
|
|
112
|
-
const name = it.file_name ? String(it.file_name) : null
|
|
113
|
-
if (it.file_path) return { path: path.resolve(outDir, String(it.file_path)), name, type: it.media_type || null }
|
|
114
|
-
if (typeof it.content === 'string') {
|
|
115
|
-
// 短内容直接在 JSON 里(小文本文件那类):自己落盘,文件名只认安全字符
|
|
116
|
-
fs.mkdirSync(outDir, { recursive: true, mode: 0o700 })
|
|
117
|
-
let base = (name || '').replace(/[^\w.-]+/g, '_').slice(0, 80)
|
|
118
|
-
if (!base || /^\.+$/.test(base)) base = `msg_media_${String(mediaId).replace(/[^\w-]+/g, '_').slice(-12)}` // 远端给的文件名洗完只剩「.」「..」之类 ⇒ 用 media_id 兜底
|
|
119
|
-
const p = path.join(outDir, base)
|
|
120
|
-
fs.writeFileSync(p, it.content, { mode: 0o600 })
|
|
121
|
-
return { path: p, name, type: it.media_type || null }
|
|
122
|
-
}
|
|
123
|
-
throw new Error('取媒体没有返回文件内容')
|
|
124
|
-
},
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
module.exports = { createWecomCli, HISTORY_DAYS, fmtBeijing }
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
// 企业微信「不 @ 也回复」的第二道门:**便宜模型门控**(纯逻辑,依赖全注入,可单测)。
|
|
2
|
-
//
|
|
3
|
-
// 第一道门是规则(wecompoll.js:点名 / 跟进窗口),零 token 但只认死规矩 —— 「刚才那个报表再发一份」这种明显在对机器人
|
|
4
|
-
// 说话却没点名、也过了跟进窗口的消息,规则只能放过。这里补第二道:规则判不了的消息,拿最近几句群聊 + 这一句去问一个
|
|
5
|
-
// **便宜的模型**「机器人该不该接这句」,它说 YES 才喂真正的 agent。问一次只花十几到一百多 token、不带会话、不起 CLI
|
|
6
|
-
// (走 provider 的 HTTP 端点,见 src/agent/askonce.js),与喂一轮 agent(几万到几十万 token 上下文)差三个数量级。
|
|
7
|
-
//
|
|
8
|
-
// 边界与保险:
|
|
9
|
-
// · 宁漏不误:问不通(超时 / 网络 / 回包看不懂)一律当 NO —— 漏答一句不要紧(人再 @ 一次就行),误答是打扰。
|
|
10
|
-
// · 不值一问的不问:太短(≤ 1 个字)、只有表情 / 标点、纯链接,直接 NO,省掉子请求。
|
|
11
|
-
// · 限速:每个群每分钟最多问 maxPerMin 次(默认 6),超出的当 NO 并记一次日志 —— 群里刷屏时别把门控自己刷成一笔开销。
|
|
12
|
-
// · 判定模型看到的是**去身份的文字**(昵称 + 正文),不给 userid、不给机器路径;正文截断到 MAX_TEXT 字。
|
|
13
|
-
// 操作者没配 gatekeeper 时整个模块不参与(wecompoll 的 gate 为 null),行为与 v0.11.110 完全一致。
|
|
14
|
-
|
|
15
|
-
const MAX_TEXT = 400 // 候选正文 / 每条上下文最多保留的字数(门控只看大意,长文截了不影响判定)
|
|
16
|
-
const MAX_RECENT = 8 // 给判定模型看的最近几句
|
|
17
|
-
const DEFAULT_PER_MIN = 6
|
|
18
|
-
|
|
19
|
-
// 「不值一问」:太短 / 只有表情标点 / 只有链接 ⇒ 不问直接 NO
|
|
20
|
-
function trivial(text) {
|
|
21
|
-
const t = String(text || '').trim()
|
|
22
|
-
if (t.length <= 1) return true
|
|
23
|
-
if (/^https?:\/\/\S+$/i.test(t)) return true
|
|
24
|
-
// 剥掉所有字母数字与 CJK 后若什么都不剩 ⇒ 只有表情 / 标点
|
|
25
|
-
const core = t.replace(/[\p{L}\p{N}]/gu, '')
|
|
26
|
-
return core.length === t.length
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const clip = (s) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > MAX_TEXT ? t.slice(0, MAX_TEXT - 1) + '…' : t }
|
|
30
|
-
|
|
31
|
-
// 判定提示词:system 定规则,user 给上下文与候选句。只要求输出 YES / NO,方便任何模型、任何语言习惯都能答对。
|
|
32
|
-
function buildGatePrompt({ botName = '', recent = [], candidate = {} } = {}) {
|
|
33
|
-
const name = botName || '机器人'
|
|
34
|
-
const system = [
|
|
35
|
-
`你是群聊里一个助手机器人「${name}」的「要不要接话」判定器。群成员刚发了一条消息,没有 @ 机器人、也没叫它的名字。`,
|
|
36
|
-
`只有当这条消息明显是在对机器人说话、或在请求机器人能做的事(查资料、看代码改代码、生成图片、读文件、回答问题、继续刚才机器人在做的任务)时,回答 YES。`,
|
|
37
|
-
`消息末尾的「[附图片]」「[附文件 …]」表示这条消息带着附件:带附件并在问「这是什么 / 看看这个 / 帮我处理」之类的,是在让机器人看,回答 YES。`,
|
|
38
|
-
`成员之间的闲聊、对某个人说的话、表情、简短的附和(「好」「收到」「哈哈」)、与机器人无关的通知,一律回答 NO。拿不准就 NO。`,
|
|
39
|
-
`只输出 YES 或 NO,不要解释。`,
|
|
40
|
-
].join('\n')
|
|
41
|
-
const ctx = (recent || []).slice(-MAX_RECENT).map((m) => `- ${m.sender || '成员'}${m.bot ? '(机器人)' : ''}:${clip(m.text)}`)
|
|
42
|
-
const user = [
|
|
43
|
-
ctx.length ? '最近的群消息(旧→新):' : '(这个群最近没有别的消息)',
|
|
44
|
-
...ctx,
|
|
45
|
-
`候选消息 —— ${candidate.sender || '成员'}:「${clip(candidate.text)}」`,
|
|
46
|
-
'机器人要不要接这一句?',
|
|
47
|
-
].join('\n')
|
|
48
|
-
return { system, user }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// 回包 → true / false / null(看不懂)。只认开头:YES / NO / 是 / 否;前面允许少量空白与标点
|
|
52
|
-
function parseVerdict(text) {
|
|
53
|
-
const t = String(text || '').trim().replace(/^[\s"'`*「」【】()()\[\]]+/, '')
|
|
54
|
-
if (/^yes\b/i.test(t) || /^是/.test(t) || /^要/.test(t)) return true
|
|
55
|
-
if (/^no\b/i.test(t) || /^否/.test(t) || /^不/.test(t)) return false
|
|
56
|
-
return null
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* @param {object} o
|
|
61
|
-
* ask — async ({system, user}) => string 问一次便宜模型(由接线层按 provider 配置实现;抛 = 问不通)
|
|
62
|
-
* botName — 机器人名(提示词里用;可给函数,现读配置)
|
|
63
|
-
* maxPerMin — 每群每分钟最多问几次(默认 6;0 = 不限)
|
|
64
|
-
* now / log — 注入时钟 / 日志
|
|
65
|
-
* 返回 gate(chatId, { text, sender, recent }) => Promise<boolean>
|
|
66
|
-
*/
|
|
67
|
-
function createGatekeeper({ ask, botName = '', maxPerMin = DEFAULT_PER_MIN, now = () => Date.now(), log = () => {} } = {}) {
|
|
68
|
-
if (typeof ask !== 'function') throw new Error('gatekeeper: ask required')
|
|
69
|
-
const asked = new Map() // chatId → [时刻…](最近一分钟)
|
|
70
|
-
const stats = { asked: 0, yes: 0, no: 0, failed: 0, skipped: 0, throttled: 0 }
|
|
71
|
-
let failStreak = 0
|
|
72
|
-
|
|
73
|
-
const underLimit = (chatId) => {
|
|
74
|
-
if (!(maxPerMin > 0)) return true
|
|
75
|
-
const t = now()
|
|
76
|
-
const arr = (asked.get(chatId) || []).filter((x) => t - x < 60_000)
|
|
77
|
-
if (arr.length >= maxPerMin) { asked.set(chatId, arr); return false }
|
|
78
|
-
arr.push(t); asked.set(chatId, arr)
|
|
79
|
-
return true
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function gate(chatId, { text, sender, recent } = {}) {
|
|
83
|
-
if (trivial(text)) { stats.skipped++; return false }
|
|
84
|
-
if (!underLimit(chatId)) {
|
|
85
|
-
stats.throttled++
|
|
86
|
-
if (stats.throttled === 1 || stats.throttled % 50 === 0) log(`企业微信门控限速(…${String(chatId).slice(-8)}):一分钟内超过 ${maxPerMin} 条,多出的不问直接不答(累计 ${stats.throttled})`)
|
|
87
|
-
return false
|
|
88
|
-
}
|
|
89
|
-
stats.asked++
|
|
90
|
-
let raw
|
|
91
|
-
try { raw = await ask(buildGatePrompt({ botName: typeof botName === 'function' ? botName() : botName, recent, candidate: { text, sender } })) } catch (e) {
|
|
92
|
-
stats.failed++; failStreak++
|
|
93
|
-
if (failStreak <= 3 || failStreak % 30 === 0) log(`企业微信门控问不通(第 ${failStreak} 次,当作不答): ` + String((e && e.message) || e).slice(0, 160))
|
|
94
|
-
return false
|
|
95
|
-
}
|
|
96
|
-
failStreak = 0
|
|
97
|
-
const v = parseVerdict(raw)
|
|
98
|
-
if (v === null) { stats.failed++; log('企业微信门控回答看不懂(当作不答): ' + String(raw).slice(0, 60)); return false }
|
|
99
|
-
if (v) stats.yes++; else stats.no++
|
|
100
|
-
// 每次判定一行日志:NO 路径不然看不见(真机 11:44 一条带图的问句被判 NO,翻日志只看到「门控生效」,查不出判了什么);问过的才记,已经限速
|
|
101
|
-
log(`企业微信门控 ${v ? 'YES' : 'NO'}(…${String(chatId).slice(-8)}):${String(text).replace(/\s+/g, ' ').slice(0, 40)}`)
|
|
102
|
-
return v
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return Object.assign(gate, { stats: () => ({ ...stats }) })
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
module.exports = { createGatekeeper, buildGatePrompt, parseVerdict, trivial, MAX_RECENT, DEFAULT_PER_MIN }
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
// 企业微信机器人的名字从哪来:平台没有「取机器人信息」的接口(长连接命令集 / 官方命令行工具都没有),但群成员 @ 它时
|
|
2
|
-
// 消息正文里就带着「@SintBot」—— 而长连接只推 @ 机器人的消息,所以每条群消息里必有它的名字。这里只做纯逻辑:
|
|
3
|
-
// mentionsOf(text) 正文里的 @ 名字列表(名字到空白 / 全角标点为止;两个人名连着 @ 时各算各的)
|
|
4
|
-
// guessBotName(msgs) 一批群历史消息里出现最多的那个 @ 名字 ⇒ 机器人名(群里被 @ 最多的就是它;别人也会被 @,但没它多)
|
|
5
|
-
// 守护进程收到 @ 消息时现学现用(wecom.js),控制台「刷新身份」扫近几天群历史统计后落进配置(server.js)。
|
|
6
|
-
// 手填框去掉了:填错一个字,剥 @ 和点名判断就全失效,而对不对只有群里知道。
|
|
7
|
-
|
|
8
|
-
const MENTION_RE = /@([^\s@,,。!!??::;;、()()\[\]【】]+)/g
|
|
9
|
-
|
|
10
|
-
// 零宽字符(U+200B~200D / 2060 / FEFF)不在 JS 的 \s 里,手机端粘贴夹带时会学成「SintBot\u200b」这种名字 —— 与剥 @ 那边(wecom.js ZERO_WIDTH_RE)同口径,入口先去掉
|
|
11
|
-
const ZERO_WIDTH_RE = /[\u200b-\u200d\u2060\ufeff]/g
|
|
12
|
-
function mentionsOf(text) {
|
|
13
|
-
const out = []
|
|
14
|
-
const s = String(text || '').replace(ZERO_WIDTH_RE, '')
|
|
15
|
-
let m
|
|
16
|
-
while ((m = MENTION_RE.exec(s))) { const n = m[1].trim(); if (n) out.push(n) }
|
|
17
|
-
return out
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// 一条历史消息的所有文字片段(text / 图文混排里的 text 项;两套字段名都认)
|
|
21
|
-
function textsOf(m) {
|
|
22
|
-
if (!m) return []
|
|
23
|
-
if (m.msg_type === 'text') return [String((m.text && m.text.content) || '')]
|
|
24
|
-
if (m.msg_type === 'mixed') return ((m.mixed && (m.mixed.items || m.mixed.msg_item)) || []).filter((it) => it && it.msg_type === 'text').map((it) => String((it.text && it.text.content) || ''))
|
|
25
|
-
return []
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// 返回 { name, count, total }:total = 含 @ 的消息数;name = null 表示一条 @ 都没见到
|
|
29
|
-
function guessBotName(msgs) {
|
|
30
|
-
const counts = new Map()
|
|
31
|
-
let total = 0
|
|
32
|
-
for (const m of msgs || []) {
|
|
33
|
-
const names = new Set(textsOf(m).flatMap(mentionsOf))
|
|
34
|
-
if (!names.size) continue
|
|
35
|
-
total++
|
|
36
|
-
for (const n of names) counts.set(n, (counts.get(n) || 0) + 1)
|
|
37
|
-
}
|
|
38
|
-
let best = null, bestN = 0
|
|
39
|
-
for (const [n, c] of counts) if (c > bestN) { best = n; bestN = c }
|
|
40
|
-
return { name: best, count: bestN, total }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
module.exports = { mentionsOf, guessBotName, textsOf }
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
// 企业微信「不 @ 也回复」:轮询群历史补入站(纯逻辑,依赖全注入,可单测)。
|
|
2
|
-
//
|
|
3
|
-
// 为什么要它:长连接只推 @ 机器人的消息(平台边界,10-10 真机实证),而官方命令行工具能拉近 7 天群历史、含不 @ 的
|
|
4
|
-
// 人类发言。所以「不 @ 也回复」= 每隔 intervalMs 拉一次已绑群的历史,把新出现的人类消息喂给引擎;回复走主动推送。
|
|
5
|
-
// 轮询本身零 token(HTTP 调用,不经模型);**真正烧 token 的是喂模型** —— 一条就是一轮、带整段会话上下文。
|
|
6
|
-
// 所以这里有一道**规则门控**(零 token),只放行两类:
|
|
7
|
-
// ① 消息里点了机器人的名(botName / keywords 里任一,如「机器人」「助手」);
|
|
8
|
-
// ② 跟进窗口内:机器人在这个群上一次被 @ 或上一次开口之后 followMs 内的消息(「@ 一次开了头,接下来几句不用再 @」)。
|
|
9
|
-
// 其余只留在历史里不喂(agent 要上下文时自己 history)。followMs = 0 ⇒ 不设窗口,全部都喂(明知烧 token 的 operator 自己选)。
|
|
10
|
-
// 第二道门「便宜模型门控」(规则不放行的再问一个便宜 provider 该不该接)由 wecomgate.js 实现、经 gate 选项注入;没配就只有规则这一道。
|
|
11
|
-
//
|
|
12
|
-
// 去重两道:① 与长连接已投递的 @ 消息去重(同一群、剥 @ 后正文相同、时间差 ≤ DUP_WINDOW_MS)—— 否则每条 @ 消息会被答两遍;
|
|
13
|
-
// ② 同一秒内的重叠:水位按 send_time 字面量(秒级)推进,边界那一秒的消息靠 (send_time|userid|content) 键去重。
|
|
14
|
-
// 启动第一轮只建基线不投递(否则把近 7 天的旧消息全灌进去)。
|
|
15
|
-
|
|
16
|
-
const DUP_WINDOW_MS = 3 * 60_000
|
|
17
|
-
|
|
18
|
-
// 工具给的 send_time 是北京时间字面量「YYYY-MM-DD HH:MM:SS」⇒ 毫秒
|
|
19
|
-
function parseBeijing(s) {
|
|
20
|
-
const m = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(String(s || '').trim())
|
|
21
|
-
if (!m) return NaN
|
|
22
|
-
return Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4] - 8, +m[5], +m[6])
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// 历史消息 → 片段:{ type:'text', text } | { type:'media', kind:'image'|'file'|'video', mediaId, name }。
|
|
26
|
-
// 历史列表里的媒体只有 media_id(内容要另调取媒体接口),所以这里只拆不下载 —— 下不下载由 pollChat 按门控结果决定:
|
|
27
|
-
// 门控不放行的消息根本不喂,也就不必为它起一次子进程去取图。语音有转写就当文字(不下载音频)。
|
|
28
|
-
function messageParts(m) {
|
|
29
|
-
if (!m) return []
|
|
30
|
-
const media = (kind, obj) => ({ type: 'media', kind, mediaId: (obj && obj.media_id) || null, name: (obj && obj.file_name) || null })
|
|
31
|
-
switch (m.msg_type) {
|
|
32
|
-
case 'text': return [{ type: 'text', text: String((m.text && m.text.content) || '') }]
|
|
33
|
-
// ⚠ 历史接口的图文混排是 `mixed.items`(schema MixedContent.items,真机 10-12 一条带字的图就这样被拆成空、静默略过),
|
|
34
|
-
// 长连接回调那边是 `mixed.msg_item` —— 两套字段名,两个都认
|
|
35
|
-
case 'mixed': return ((m.mixed && (m.mixed.items || m.mixed.msg_item)) || []).map((it) => it && it.msg_type === 'text' ? { type: 'text', text: String((it.text && it.text.content) || '') } : (it && it.msg_type === 'image' ? media('image', it.image) : null)).filter(Boolean)
|
|
36
|
-
case 'image': return [media('image', m.image)]
|
|
37
|
-
case 'file': return [media('file', m.file)]
|
|
38
|
-
case 'voice': return [{ type: 'text', text: String((m.voice && m.voice.content) || '') ? '(语音转文字)' + m.voice.content : '[语音]' }]
|
|
39
|
-
case 'video': return [media('video', m.video)]
|
|
40
|
-
default: return []
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
const mediaLabel = (kind) => (kind === 'image' ? '图片' : kind === 'video' ? '视频' : '文件')
|
|
44
|
-
// 片段 → 一行正文(媒体给占位标记):去重键与日志用;喂引擎的正文由 pollChat 另拼(媒体换成本机路径标记)
|
|
45
|
-
function messageText(m) {
|
|
46
|
-
return messageParts(m).map((p) => p.type === 'text' ? p.text : `[${mediaLabel(p.kind)}${p.name ? ' ' + p.name : ''}]`).filter(Boolean).join(' ')
|
|
47
|
-
}
|
|
48
|
-
// 只取文字片段(去重与点名判定用:长连接那边记的是剥 @ 后的纯文字,不含媒体标记 —— 只发一张图 @ 机器人时两边都是空串才对得上)
|
|
49
|
-
const plainText = (m) => messageParts(m).filter((p) => p.type === 'text').map((p) => p.text).filter(Boolean).join(' ')
|
|
50
|
-
// 本机路径标记,与长连接入站那条(wecom.js mediaMark)同一形状 —— 清单只教了一种写法
|
|
51
|
-
const defaultMark = (kind, p) => `[${mediaLabel(kind)} path=${p}]`
|
|
52
|
-
|
|
53
|
-
function createHistoryPoll({
|
|
54
|
-
chats = () => [], // () => 要轮询的群 id 列表(每 tick 现读,跟随配置热重载)
|
|
55
|
-
list, // (chatId) => Promise<messages[]>(工具的 chat messages list)
|
|
56
|
-
deliver, // (msg) => void 把一条人类消息喂给引擎(InboundMessage 形状)
|
|
57
|
-
wasDelivered = () => false, // (chatId, text, tMs, {userId,userName}) => bool 长连接是否已投递过这条(去重 @ 消息;text = 剥 @ 后的纯文字,不含媒体标记;meta 让适配器把账号 ↔ 姓名对上)
|
|
58
|
-
stripMention = (t) => t, // 剥正文里的 @机器人
|
|
59
|
-
fetchMedia = null, // async ({kind, mediaId, name}, base) => 本机路径;null = 不下载,媒体只给「[图片]」占位(没装命令行工具时)
|
|
60
|
-
mark = defaultMark, // (kind, path) => 正文里的本机路径标记(与长连接入站同形)
|
|
61
|
-
gate = null, // 第二道门(便宜模型门控,wecomgate.js):async (chatId, {text, sender, recent}) => bool;规则不放行时才问;null = 没配
|
|
62
|
-
now = () => Date.now(), setTimer = setTimeout, clearTimer = clearTimeout,
|
|
63
|
-
intervalMs = 20_000, followMs = 5 * 60_000,
|
|
64
|
-
// 分档间隔(两车道,v0.11.115):intervalMs 是「温」档(配置里的 pollSec);热 = 跟进窗口内(刚被 @ / 刚开口 / 刚喂过),
|
|
65
|
-
// 冷 = 这个群 idleMs 内没有任何人类发言;授权 / 规模门控类错误(850003 / 853006)整体暂停 pauseMs 再试 —— 刷也刷不出来,只会刷出告警
|
|
66
|
-
hotMs = 8_000, coldMs = 0, idleMs = 10 * 60_000, pauseMs = 30 * 60_000,
|
|
67
|
-
botName = '', keywords = ['机器人', '助手'],
|
|
68
|
-
log = () => {},
|
|
69
|
-
} = {}) {
|
|
70
|
-
const state = new Map() // chatId → { last: send_time 字面量, keys: Set }
|
|
71
|
-
const followUntil = new Map() // chatId → 跟进窗口截止(ms)
|
|
72
|
-
const nextAt = new Map() // chatId → 下次该拉的时刻(ms);没记录 = 立刻
|
|
73
|
-
const lastHumanAt = new Map() // chatId → 历史里最后一条人类消息的时刻(ms),喂没喂都记 —— 分档用
|
|
74
|
-
let pausedUntil = 0 // 授权类错误 ⇒ 整体暂停到这个时刻
|
|
75
|
-
const coldOf = () => (coldMs > 0 ? coldMs : Math.max(6 * intervalMs, 120_000))
|
|
76
|
-
const hotOf = () => Math.min(hotMs, intervalMs)
|
|
77
|
-
// 这个群现在该多久拉一次:热 / 温 / 冷
|
|
78
|
-
const tierMs = (chatId) => {
|
|
79
|
-
const t = now()
|
|
80
|
-
if ((followUntil.get(chatId) || 0) > t) return hotOf()
|
|
81
|
-
const lh = lastHumanAt.get(chatId)
|
|
82
|
-
if (lh == null || !(t - lh >= idleMs)) return intervalMs // 没见过 = 温
|
|
83
|
-
return coldOf()
|
|
84
|
-
}
|
|
85
|
-
const tierName = (chatId) => { const ms = tierMs(chatId); return ms === hotOf() && (followUntil.get(chatId) || 0) > now() ? 'hot' : ms === intervalMs ? 'warm' : 'cold' }
|
|
86
|
-
const recent = new Map() // chatId → [{sender, text}] 最近几句(只给第二道门当上下文;喂没喂都记,机器人自己的话读不到 —— 历史里没有)
|
|
87
|
-
const RECENT_KEEP = 8
|
|
88
|
-
const remember = (chatId, sender, text) => { const arr = recent.get(chatId) || []; arr.push({ sender, text }); if (arr.length > RECENT_KEEP) arr.shift(); recent.set(chatId, arr) }
|
|
89
|
-
let timer = null, stopped = true, failStreak = 0
|
|
90
|
-
// botName / keywords 可以是函数(现读配置,控制台改了热生效);每次判定现算名单,几个字符串的开销可忽略
|
|
91
|
-
const names = () => { const b = typeof botName === 'function' ? botName() : botName; let k = typeof keywords === 'function' ? keywords() : keywords; if (!Array.isArray(k)) k = ['机器人', '助手']; return [b, ...k].map((x) => String(x || '').trim().toLowerCase()).filter(Boolean) }
|
|
92
|
-
|
|
93
|
-
const mentioned = (text) => { const t = String(text || '').toLowerCase(); return names().some((n) => t.includes(n)) }
|
|
94
|
-
// 规则门控:点名 ⇒ 喂;跟进窗口内 ⇒ 喂;followMs 为 0 ⇒ 全喂
|
|
95
|
-
const shouldFeed = (chatId, text, tMs) => {
|
|
96
|
-
if (followMs === 0) return true
|
|
97
|
-
if (mentioned(text)) return true
|
|
98
|
-
return tMs <= (followUntil.get(chatId) || 0)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async function pollChat(chatId) {
|
|
102
|
-
let msgs
|
|
103
|
-
try { msgs = await list(chatId) } catch (e) {
|
|
104
|
-
failStreak++
|
|
105
|
-
if (failStreak <= 3 || failStreak % 30 === 0) log(`企业微信群历史轮询失败(…${String(chatId).slice(-8)},第 ${failStreak} 次): ` + String((e && e.message) || e).slice(0, 160))
|
|
106
|
-
if (e && e.code === 'ENOENT') { stop(); log('企业微信命令行工具未安装,「不 @ 也回复」已停') }
|
|
107
|
-
// 850003 = 工具能力授权过期(创建者点链接续期)· 853006 = 企业规模门控(官方未开放):都不是刷能刷出来的,整体歇 pauseMs 再试
|
|
108
|
-
else if (e && /^(850003|853006)$/.test(String(e.code))) { pausedUntil = now() + pauseMs; log(`企业微信「不 @ 也回复」暂停 ${Math.round(pauseMs / 60_000)} 分钟(${e.code}:${e.code === '853006' || e.code === 853006 ? '企业规模门控,官方未对本企业开放读群历史' : '工具能力授权过期,要创建者续期'})`) }
|
|
109
|
-
return
|
|
110
|
-
}
|
|
111
|
-
failStreak = 0
|
|
112
|
-
const st = state.get(chatId)
|
|
113
|
-
const sorted = (msgs || []).filter((m) => m && m.send_time).sort((a, b) => String(a.send_time).localeCompare(String(b.send_time)))
|
|
114
|
-
if (sorted.length) { const lt = parseBeijing(String(sorted[sorted.length - 1].send_time)); if (Number.isFinite(lt)) lastHumanAt.set(chatId, lt) }
|
|
115
|
-
if (!st) { // 第一轮:只建基线
|
|
116
|
-
const last = sorted.length ? String(sorted[sorted.length - 1].send_time) : ''
|
|
117
|
-
state.set(chatId, { last, keys: new Set(sorted.filter((m) => String(m.send_time) === last).map(keyOf)) })
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
const fed = [] // 本轮放行的消息:攒成一批投递(一轮答完),不是每条各起一轮
|
|
121
|
-
for (const m of sorted) {
|
|
122
|
-
const t = String(m.send_time)
|
|
123
|
-
if (t < st.last) continue
|
|
124
|
-
const k = keyOf(m)
|
|
125
|
-
if (st.keys.has(k)) continue
|
|
126
|
-
if (t > st.last) { st.last = t; st.keys = new Set() }
|
|
127
|
-
st.keys.add(k)
|
|
128
|
-
const parts = messageParts(m)
|
|
129
|
-
const plain = stripMention(plainText(m)).replace(/\s+/g, ' ').trim()
|
|
130
|
-
const medias = parts.filter((p) => p.type === 'media')
|
|
131
|
-
if (!plain && !medias.length) continue
|
|
132
|
-
const tMs = parseBeijing(t)
|
|
133
|
-
// 去重与门控都按纯文字判:长连接那边记的也是纯文字(只发一张图 @ 机器人 ⇒ 两边都是空串,才对得上;拿带「[图片]」的串去比永远对不上 = 同一张图答两遍)
|
|
134
|
-
const sender = String(m.user_name || m.userid || '')
|
|
135
|
-
if (wasDelivered(chatId, plain, tMs, { userId: String(m.userid || ''), userName: String(m.user_name || '') })) { remember(chatId, sender, plain || messageText(m)); continue } // 长连接已投递的 @ 消息(上下文照记,纯图记成占位;meta 给适配器桥接姓名)
|
|
136
|
-
// 第一道门(规则,零 token)不放行 ⇒ 有第二道门(便宜模型)就问它;它说不要、或没配 ⇒ 不喂。
|
|
137
|
-
// 先 remember 再问:给门控看的上下文不含这一句本身(它在「候选消息」里单独给)
|
|
138
|
-
const ctx = (recent.get(chatId) || []).slice()
|
|
139
|
-
remember(chatId, sender, plain || messageText(m))
|
|
140
|
-
if (!shouldFeed(chatId, plain, tMs)) {
|
|
141
|
-
if (!gate || !plain) continue // 纯媒体没有文字可判,不问
|
|
142
|
-
// 候选句要带上附件提示:门控看不到图,「这是什么界面」配一张图是在问机器人,光看文字像闲聊(真机 11:44 判成了 NO)
|
|
143
|
-
const hint = medias.length ? ' ' + medias.map((md) => `[附${mediaLabel(md.kind)}${md.name ? ' ' + md.name : ''}]`).join(' ') : ''
|
|
144
|
-
let ok = false
|
|
145
|
-
try { ok = await gate(chatId, { text: plain + hint, sender, recent: ctx }) } catch (e) { log('企业微信门控异常(当作不答): ' + (e && e.message)) }
|
|
146
|
-
if (!ok) continue
|
|
147
|
-
}
|
|
148
|
-
// 门控放行了才去取媒体(每个媒体一次子进程):取到给本机路径标记,取不到给一句能看懂的失败标记 —— agent 至少知道「这里有张图我没拿到」
|
|
149
|
-
const marks = []
|
|
150
|
-
const stamp = t.replace(/\D/g, '')
|
|
151
|
-
for (let i = 0; i < medias.length; i++) {
|
|
152
|
-
const md = medias[i]
|
|
153
|
-
if (!fetchMedia || !md.mediaId) { marks.push(`[${mediaLabel(md.kind)}${md.name ? ' ' + md.name : ''}]`); continue }
|
|
154
|
-
try { marks.push(mark(md.kind, await fetchMedia(md, `poll-${stamp}-${hash(k)}-${i + 1}`))) }
|
|
155
|
-
// ⚠ 失败标记不带错误原话:原话里有命令行工具名 / 机器路径(ENOENT 的完整路径、工具 usage),agent 会顺手复述给群成员;它只需要知道「这张图没拿到」
|
|
156
|
-
catch (e) { log(`企业微信群历史媒体下载失败(${md.kind}): ` + String((e && e.message) || e).slice(0, 160)); marks.push(`[${mediaLabel(md.kind)} 下载失败]`) }
|
|
157
|
-
}
|
|
158
|
-
const text = [plain, ...marks].filter(Boolean).join('\n')
|
|
159
|
-
// label = 给推送引用行用的原话:纯文字,媒体只给「[图片 a.png]」这种不带本机路径的标记(用户面零路径)
|
|
160
|
-
fed.push({ userId: String(m.userid || ''), sender, text, label: plain || messageText(m), stamp, k, tMs: Number.isFinite(tMs) ? tMs : now(), hhmm: t.slice(11, 16) })
|
|
161
|
-
}
|
|
162
|
-
if (!fed.length) return
|
|
163
|
-
// 攒批:同一轮拉到的多条放行消息合成一条投递 —— 引擎按一轮处理,回复一次。多条时每行带「[时分 发送者]」,
|
|
164
|
-
// 代表(msgId / 时间 / 发送者)取末条,与引擎出队合并的口径一致;推送回复要引用的也是末条。
|
|
165
|
-
const last = fed[fed.length - 1]
|
|
166
|
-
const body = fed.length === 1 ? last.text : fed.map((f) => `[${f.hhmm} ${f.sender}] ${f.text}`).join('\n')
|
|
167
|
-
try {
|
|
168
|
-
deliver({
|
|
169
|
-
chatId,
|
|
170
|
-
userId: last.userId,
|
|
171
|
-
senderName: last.sender,
|
|
172
|
-
text: body,
|
|
173
|
-
msgId: `poll:${chatId}:${last.stamp}:${hash(last.k)}`,
|
|
174
|
-
createTimeMs: last.tMs,
|
|
175
|
-
// 没有 req_id ⇒ 回复走主动推送、没有流式;quote 给适配器在推送开头引用原话(推送不会自动引用,群里分不清在回谁)
|
|
176
|
-
// 攒批时引用行要让群里看出「这一条回了好几句」:more = 末条之前还有几条;senders = 几个人在说
|
|
177
|
-
replyCtx: { chatId, chatType: 'group', quote: { sender: last.sender, text: String(last.label || '').slice(0, 40), more: fed.length - 1, senders: new Set(fed.map((f) => f.sender)).size } },
|
|
178
|
-
polled: true,
|
|
179
|
-
batchOf: fed.length,
|
|
180
|
-
})
|
|
181
|
-
} catch (e) { log('企业微信轮询投递异常: ' + (e && e.message)) }
|
|
182
|
-
bump(chatId, now() + hotOf()) // 喂过 ⇒ 下次拉取按热档提前(跟进窗口由机器人开口时开,这里不开 —— 门控口径不变)
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const chatIds = () => { try { return (chats() || []).filter((id) => /^wr/.test(String(id))) } catch { return [] } }
|
|
186
|
-
// 一个定时器,到点扫一遍:只拉到期的群,拉完按各自档位记下次;定时器定在最早到期的那个群。
|
|
187
|
-
// 手动调 tick()(测试 / 排障)= 不看档位全拉一遍;定时器走 tick(false) 只拉到期的
|
|
188
|
-
// ⚠ 不可重入:一轮 tick 里 await 着门控 / 媒体下载时,kick / noteActivity 会重定定时器 —— 若放任第二个 tick 并发跑同一个群,
|
|
189
|
-
// 两份 pollChat 会乱序投递(后一份先投了更新的消息,前一份随后投的旧消息被引擎水位线当陈旧丢掉 = 静默吞消息)。
|
|
190
|
-
// 跑着时只记「要再来一次」,末尾 arm 时 nextAt 已被 bump 改过,自然算出立即到期。
|
|
191
|
-
let running = false
|
|
192
|
-
async function tick(force = true) {
|
|
193
|
-
if (stopped || running) return
|
|
194
|
-
running = true
|
|
195
|
-
timer = null
|
|
196
|
-
try {
|
|
197
|
-
const ids = chatIds()
|
|
198
|
-
const t0 = now()
|
|
199
|
-
for (const id of ids) {
|
|
200
|
-
if (!force && pausedUntil > now()) break // 撞到授权 / 门控类错误:别让剩下的群各起一次子进程撞同一个错
|
|
201
|
-
if (!force && (nextAt.get(id) || 0) > t0) continue
|
|
202
|
-
try { await pollChat(id) } catch (e) { log('企业微信轮询异常(跳过本群本轮): ' + (e && e.message)) } // 串行:一次一个子进程,别把工具打爆;任何意外抛出都不许杀掉整条轮询
|
|
203
|
-
// 本轮内有人 bump 过(喂过 ⇒ 热档、答完补拉)就保留那个更早的时刻;否则按档位记下次。cur > t0 区分「新 bump」与触发本次的旧到期值
|
|
204
|
-
const cur = nextAt.get(id); const planned = now() + tierMs(id)
|
|
205
|
-
nextAt.set(id, cur != null && cur > t0 && cur < planned ? cur : planned)
|
|
206
|
-
}
|
|
207
|
-
if (pausedUntil > now()) for (const id of ids) nextAt.set(id, Math.max(nextAt.get(id) || 0, pausedUntil))
|
|
208
|
-
} finally { running = false }
|
|
209
|
-
arm()
|
|
210
|
-
}
|
|
211
|
-
function arm(ids = chatIds()) {
|
|
212
|
-
if (stopped || running) return // 跑着时不设定时器:tick 末尾会按最新的 nextAt 重定
|
|
213
|
-
if (timer) { clearTimer(timer); timer = null }
|
|
214
|
-
const t = now()
|
|
215
|
-
let due = Infinity
|
|
216
|
-
for (const id of ids) due = Math.min(due, nextAt.has(id) ? nextAt.get(id) : t)
|
|
217
|
-
if (!Number.isFinite(due)) due = t + intervalMs // 一个群都没绑:按温档再看(配置热重载可能加群)
|
|
218
|
-
timer = setTimer(() => tick(false), Math.max(0, due - t))
|
|
219
|
-
}
|
|
220
|
-
// 把某个群的下次拉取提前到 at(只提前不推后),并重定定时器
|
|
221
|
-
function bump(chatId, at) { if (!chatId || stopped) return; const cur = nextAt.get(chatId); if (cur == null || at < cur) nextAt.set(chatId, at); arm() }
|
|
222
|
-
|
|
223
|
-
function start() {
|
|
224
|
-
if (!(intervalMs > 0)) return false
|
|
225
|
-
stopped = false
|
|
226
|
-
timer = setTimer(() => tick(false), 0)
|
|
227
|
-
return true
|
|
228
|
-
}
|
|
229
|
-
function stop() { stopped = true; if (timer) { clearTimer(timer); timer = null } }
|
|
230
|
-
// 机器人在这个群被 @ 了 / 开口了 / 喂过了 ⇒ 开(续)跟进窗口,并切到热档(下次拉取提前到 hotMs 内)
|
|
231
|
-
function noteActivity(chatId) { if (!chatId) return; if (followMs > 0) followUntil.set(chatId, now() + followMs); bump(chatId, now() + hotOf()) }
|
|
232
|
-
// 补拉:delayMs 后就拉这个群一次(机器人刚答完,跟进句通常紧跟其后)
|
|
233
|
-
function kick(chatId, delayMs = 0) { bump(chatId, now() + Math.max(0, delayMs)) }
|
|
234
|
-
|
|
235
|
-
return { start, stop, tick, noteActivity, kick, shouldFeed, tierName, _state: state, _nextAt: nextAt }
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const keyOf = (m) => `${m.send_time}|${m.userid || ''}|${messageText(m)}`
|
|
239
|
-
function hash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36) }
|
|
240
|
-
|
|
241
|
-
module.exports = { createHistoryPoll, parseBeijing, messageText, messageParts, plainText, DUP_WINDOW_MS }
|