sintbot 1.4.7 → 1.4.8
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 +23 -0
- package/CHANGELOG.json +23 -0
- package/bin/sintbot-v2.js +36 -1
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +3 -3
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +3 -3
- package/install.sh +1 -1
- package/package.json +8 -1
- package/src/c2console.js +50 -3
- package/src/c2multigroupservice.js +88 -4
- package/src/c2updater.js +20 -3
- package/src/consolesay.js +229 -0
- package/src/core/c2sourceidentity.js +167 -0
- package/src/core/v2engine.js +41 -3
- package/src/feishucli.js +211 -0
- package/src/platform/noticecard.js +104 -0
- package/src/platform/noticesend.js +47 -0
- package/src/ui/console.html +52 -13
- package/src/ui/serverbase.js +19 -2
- package/src/upgrade.js +58 -0
- package/src/upgradeannounce.js +62 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// 控制台「向群里插一句」的守护进程侧动作—— **会驱动一轮**的注入,不是纯通知。
|
|
2
|
+
//
|
|
3
|
+
// ⚠ 与 notifyGroup 的区别:那条只让群里多一行字、agent 不动;这条要它真去做事。
|
|
4
|
+
// ⚠ 与定时任务 `--do` 的区别:立刻生效,不用算触发时间、不留一次性任务的残迹。
|
|
5
|
+
// `shown` 非空 ⇒ 先把这句话发进群(群成员看得见来龙去脉);为 null ⇒ 静默注入,群里只看到产出。
|
|
6
|
+
// 返回结构化结果而不是布尔:前端要**如实**告诉人「到底插进去没有、落在哪」。
|
|
7
|
+
//
|
|
8
|
+
// ⭐ 先等「【管理员】…」那句**发出去**,再注入:以前是发出去不等就注入,
|
|
9
|
+
// 注入后的第一句回执(「↪ 已加入正在处理的任务」「📝 已记下 #7」「⏳ 已排队」)常常比它还快到群里 ——
|
|
10
|
+
// 群成员读到的就成了「先回答、后提问」。发送失败或超时也照样注入:指令本身比群里那行字要紧,
|
|
11
|
+
// 卡在这里等于把管理员的指令丢了。
|
|
12
|
+
//
|
|
13
|
+
// 依赖全注入(平台、群配置、引擎入口、落点查询),纯逻辑可单测。
|
|
14
|
+
|
|
15
|
+
const { syntheticSource } = require('./core/c2sourceidentity')
|
|
16
|
+
const { taskId } = require('./core/enginecheckpoint')
|
|
17
|
+
|
|
18
|
+
const SEND_TIMEOUT_MS = 5000
|
|
19
|
+
|
|
20
|
+
// 发群里那行「【管理员】…」:等它落地,最多 sendTimeoutMs;出错 / 超时只记日志。
|
|
21
|
+
// 返回它的消息 id(平台给的话):回执与卡片要挂到它下面;拿不到就 null。
|
|
22
|
+
// onLate:超时之后发送才回来时报一声(持久路径据此在日志里留下「这行字其实发出去了」)。
|
|
23
|
+
async function sendShownLine(platform, chatId, shown, sendTimeoutMs, log, onLate = null) {
|
|
24
|
+
let timer = null
|
|
25
|
+
let timedOut = false
|
|
26
|
+
const sending = Promise.resolve().then(() => platform.send(chatId, shown))
|
|
27
|
+
if (onLate) sending.then(id => { if (timedOut) onLate(id) }, () => {})
|
|
28
|
+
try {
|
|
29
|
+
const id = await Promise.race([
|
|
30
|
+
sending,
|
|
31
|
+
new Promise((_, reject) => { timer = setTimeout(() => { timedOut = true; reject(new Error(`发送超时(${sendTimeoutMs}ms)`)) }, sendTimeoutMs) }),
|
|
32
|
+
])
|
|
33
|
+
return typeof id === 'string' && id ? id : null
|
|
34
|
+
} catch (e) {
|
|
35
|
+
log('say(shown): ' + (e && e.message))
|
|
36
|
+
return null
|
|
37
|
+
} finally {
|
|
38
|
+
if (timer) clearTimeout(timer)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createConsoleSay({ getPlatform, getGroup, handle, locate, log = () => {}, now = () => Date.now(), sendTimeoutMs = SEND_TIMEOUT_MS }) {
|
|
43
|
+
// 发群里那行「【管理员】…」:出错 / 超时只记日志、不拦注入(见 sendShownLine)。
|
|
44
|
+
const sendShown = (platform, chatId, shown) => sendShownLine(platform, chatId, shown, sendTimeoutMs, log)
|
|
45
|
+
|
|
46
|
+
// `requestId`:控制台每次提交自己生成的 32 位十六进制幂等键(静默注入时的唯一持久锚点)。
|
|
47
|
+
// 没传就没有 —— 下面只在拿得到持久锚点时才给这条合成消息一个持久收据身份。
|
|
48
|
+
return async function say(chatId, text, shown, quiet, { requestId } = {}) {
|
|
49
|
+
const platform = getPlatform()
|
|
50
|
+
if (!platform || !platform.send) return { ok: false, reason: 'no-daemon' } // `sbot ui` 纯配置模式
|
|
51
|
+
const g = getGroup(chatId)
|
|
52
|
+
if (!g) return { ok: false, reason: 'unbound' }
|
|
53
|
+
if (g.paused) return { ok: false, reason: 'paused' } // 暂停群按语义跳过,与 cron 一致
|
|
54
|
+
const shownId = shown ? await sendShown(platform, chatId, shown) : null
|
|
55
|
+
try {
|
|
56
|
+
// 时刻取在发完之后:它是这条消息的 createTimeMs,应当晚于群里那行字
|
|
57
|
+
const t = now()
|
|
58
|
+
const msgId = `console:${chatId}:${t}`
|
|
59
|
+
const body = String(text || '')
|
|
60
|
+
// 持久收据身份(C2):发出去的那行字就是锚点;静默注入只认控制台自己带的幂等键。
|
|
61
|
+
// 两者都没有 ⇒ 不给 receiptSource/receiptSourceId,让持久路径继续按「来源不可持久」拒收,
|
|
62
|
+
// 而不是拿 Date.now() 拼一个崩溃重放后会变成另一笔任务的 ID(那就是重复发言的来源)。
|
|
63
|
+
let durable = null
|
|
64
|
+
try {
|
|
65
|
+
durable = syntheticSource({ kind: 'console', chatId,
|
|
66
|
+
...(shownId ? { shownMessageId: shownId } : { requestId }) })
|
|
67
|
+
} catch { durable = null }
|
|
68
|
+
const receiptFields = durable
|
|
69
|
+
? { receiptSource: durable.source, receiptSourceId: durable.sourceId } : {}
|
|
70
|
+
// ⚠ synthetic:true 是关键 —— 它让这条消息**不推水位线**(合成消息的时间戳来自本机
|
|
71
|
+
// Date.now(),与平台消息不是同一把钟;拿它推水位线会静默吞掉真人消息)。
|
|
72
|
+
// wantsAck:插话勾了「回复」= operator 就在等结果 ⇒ 该走 ack(合成消息默认不 ack)。
|
|
73
|
+
// operator:只决定插进当前任务时的来源标签(「管理员插话」);能不能并进当前任务看的是 wantsAck(有人在等),判据在引擎。
|
|
74
|
+
// replyCtx 带上群里那行字的消息 id:排队回执 / 答案卡片就挂到它下面,与群成员自己发的消息长得一样;
|
|
75
|
+
// 没发那行 / 发失败 ⇒ 只有 chatId,平台退回直发(答案照样到群里,只是不带引用)。
|
|
76
|
+
// visible = 这句话本身在群里看得见:引擎据此决定忙时跟进时卡片要不要跟过来 —— 看不见的补话没有可跟的消息。
|
|
77
|
+
handle({ chatId, userId: 'console', senderName: '管理员', synthetic: true, operator: true, quiet: !!quiet, wantsAck: !quiet, visible: !!shownId, text: body, msgId, createTimeMs: t, replyCtx: shownId ? { chatId, messageId: shownId } : { chatId }, ...receiptFields })
|
|
78
|
+
// 去向在 handle 的第一个 await 之前就已定下(见 engine.locateMessage),这里**同步**读到的就是落点 ——
|
|
79
|
+
// ⚠ 所以 handle 与 locate 之间不许插 await。
|
|
80
|
+
const where = body.trim().startsWith('/') ? { state: 'command' } : locate(chatId, msgId)
|
|
81
|
+
return { ok: true, where }
|
|
82
|
+
} catch (e) {
|
|
83
|
+
log('say: ' + (e && e.message))
|
|
84
|
+
return { ok: false, reason: 'inject-failed' }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── 持久处理流程(sintbot-v2)的插话,第 1 版:只接「空闲群 · 这句话进群 · 回复进群」 ──
|
|
90
|
+
//
|
|
91
|
+
// 群里那行「【管理员】…」就是驱动这一轮的那条平台消息:它的 om_ 消息 id 就是这笔任务的来源身份
|
|
92
|
+
// (source 'message'),与群成员自己发的一条消息同形 —— 收据、回复锚点、/stop、重启后的中断告知
|
|
93
|
+
// (回在这行字下面)、事故封存全走现有的持久路径,引擎和日志一处不改。所以顺序是硬的:先等这行字
|
|
94
|
+
// 发出去、拿到平台给的消息 id,才会有收据;发失败 / 超时 / 没拿到 id ⇒ 不注入、如实报错 ——
|
|
95
|
+
// 没有锚点就没有持久身份,绝不拿本机时间拼一个(崩溃重放后会变成另一笔任务)。
|
|
96
|
+
//
|
|
97
|
+
// 第 1 版不做的,都在发群消息**之前**就拒,群里不留痕:
|
|
98
|
+
// · 静默插话(这句不进群):没有平台消息可锚,需要另一种来源和重启告知路径;
|
|
99
|
+
// · 不回复的插话(quiet):持久路径还不接静默收据;
|
|
100
|
+
// · 群正忙:不排队、也不加入当前任务;
|
|
101
|
+
// · 斜杠命令:群命令不走插话(停止、开新会话在概览里有按钮)。
|
|
102
|
+
//
|
|
103
|
+
// requestId:控制台每次提交带的 32 位十六进制幂等键(经 syntheticSource 校验并折成键)。发出那行字
|
|
104
|
+
// 之后才记下「这个键 → 那行字」;同一个键再来:那行字已有收据 ⇒ 报 duplicate、不再处理;还没有收据
|
|
105
|
+
// (上次在发出之后被拒)⇒ 沿用群里已有的那行再试,不发第二行。这张表只在本进程内有效:服务重启后
|
|
106
|
+
// 同一个键当作新的一次;已登记的那笔由启动恢复按现有规则收尾(回在那行字下面),不会重跑。
|
|
107
|
+
const FEISHU_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
|
|
108
|
+
const CONFIRM_MS = 5000
|
|
109
|
+
const POLL_MS = 10
|
|
110
|
+
const MAX_REQUESTS = 256
|
|
111
|
+
// 群里被拒时(注入后、收据前)服务给的原因 → 控制台的说法
|
|
112
|
+
const REJECTION_REASONS = { 'group-held': 'held', 'group-recovering': 'recovering',
|
|
113
|
+
upgrading: 'upgrading', 'stop-pending': 'busy', 'skip-pending': 'busy' }
|
|
114
|
+
|
|
115
|
+
function refusalOf(value) {
|
|
116
|
+
if (value && value.quarantined) return 'quarantined'
|
|
117
|
+
if (value && value.held) return 'held'
|
|
118
|
+
if (value && value.recovered) return 'recovering'
|
|
119
|
+
if (value && value.ignored) return 'not-serving'
|
|
120
|
+
if (value && value.rejected) return REJECTION_REASONS[value.reason] || 'refused'
|
|
121
|
+
return 'inject-failed'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 依赖全注入:
|
|
125
|
+
// getPlatform(chatId) 该群真实的平台适配器(与群成员消息进来时同一个),没有 ⇒ null
|
|
126
|
+
// getGroup(chatId) 群配置
|
|
127
|
+
// gate(chatId) 此刻能不能插:null = 能;否则原因('busy' | 'held' | 'upgrading' | 'not-serving' …)
|
|
128
|
+
// route(msg, platform) 服务的统一入口(routeMessage):暂停 / 待人工 / 收尾中 / 升级中都在那里把关
|
|
129
|
+
// receiptState(chatId, id) 该群日志里这笔收据:'pending' | 'completed' | null
|
|
130
|
+
function createDurableConsoleSay({ getPlatform, getGroup, gate, route, receiptState,
|
|
131
|
+
log = () => {}, now = () => Date.now(), sendTimeoutMs = SEND_TIMEOUT_MS,
|
|
132
|
+
confirmMs = CONFIRM_MS, pollMs = POLL_MS, maxRequests = MAX_REQUESTS } = {}) {
|
|
133
|
+
for (const hook of [getPlatform, getGroup, gate, route, receiptState, log, now]) {
|
|
134
|
+
if (typeof hook !== 'function') throw new TypeError('durable console say needs explicit hooks')
|
|
135
|
+
}
|
|
136
|
+
const posted = new Map() // 请求键 → 群里那行字的消息 id(按插入顺序,超过上限丢最早的)
|
|
137
|
+
const inflight = new Map() // 请求键 → 进行中的那一次:同一个键并发再来,排在它后面
|
|
138
|
+
const remember = (key, shownId) => {
|
|
139
|
+
posted.delete(key)
|
|
140
|
+
posted.set(key, shownId)
|
|
141
|
+
while (posted.size > maxRequests) posted.delete(posted.keys().next().value)
|
|
142
|
+
}
|
|
143
|
+
const receiptOf = (chatId, shownId) => taskId({ source: 'message', chatId, sourceId: shownId })
|
|
144
|
+
|
|
145
|
+
// route 要等这一轮跑完才回来;收据一登记就算插进去了。轮询该群日志,最多 confirmMs。
|
|
146
|
+
function watch(chatId, receiptId, settled) {
|
|
147
|
+
let stopped = false
|
|
148
|
+
settled.then(() => { stopped = true })
|
|
149
|
+
return (async () => {
|
|
150
|
+
const deadline = Date.now() + confirmMs
|
|
151
|
+
while (!stopped && Date.now() < deadline) {
|
|
152
|
+
if (receiptState(chatId, receiptId)) return { admitted: true }
|
|
153
|
+
await new Promise(resolve => setTimeout(resolve, pollMs))
|
|
154
|
+
}
|
|
155
|
+
return receiptState(chatId, receiptId) ? { admitted: true } : { timeout: true }
|
|
156
|
+
})()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function attempt(platform, chatId, body, shown, key) {
|
|
160
|
+
let shownId = posted.get(key) || null
|
|
161
|
+
if (shownId && receiptState(chatId, receiptOf(chatId, shownId))) {
|
|
162
|
+
return { ok: true, where: { state: 'duplicate' } }
|
|
163
|
+
}
|
|
164
|
+
const closed = gate(chatId)
|
|
165
|
+
if (closed) return { ok: false, reason: closed }
|
|
166
|
+
if (!shownId) {
|
|
167
|
+
shownId = await sendShownLine(platform, chatId, shown, sendTimeoutMs, log,
|
|
168
|
+
late => log('say(shown): 超时后才发出' + (typeof late === 'string' && late ? `(${late})` : '') + ',这条插话未处理'))
|
|
169
|
+
if (!shownId || !FEISHU_MESSAGE_ID.test(shownId)) {
|
|
170
|
+
if (shownId) log('say(shown): 平台给的消息 id 不能当持久来源,未注入')
|
|
171
|
+
return { ok: false, reason: 'shown-failed' }
|
|
172
|
+
}
|
|
173
|
+
remember(key, shownId)
|
|
174
|
+
// 发这行字的这会儿群里可能刚开始了别的任务:此时不注入(第 1 版忙时不排队、不加入)
|
|
175
|
+
const late = gate(chatId)
|
|
176
|
+
if (late) return { ok: false, reason: late === 'busy' ? 'busy-after-post' : late }
|
|
177
|
+
}
|
|
178
|
+
const receiptId = receiptOf(chatId, shownId)
|
|
179
|
+
// 与平台送进来的一条群消息同形:没有 synthetic / quiet / wantsAck,msgId 与回复锚点都是那行字。
|
|
180
|
+
// 时刻取在发完之后,晚于群里那行字。
|
|
181
|
+
const msg = { chatId, userId: 'console', senderName: '管理员', text: body, msgId: shownId,
|
|
182
|
+
createTimeMs: now(), replyCtx: { chatId, messageId: shownId } }
|
|
183
|
+
const settled = Promise.resolve().then(() => route(msg, platform))
|
|
184
|
+
.then(value => ({ value }), error => ({ error }))
|
|
185
|
+
const first = await Promise.race([settled, watch(chatId, receiptId, settled)])
|
|
186
|
+
if (first.admitted || first.timeout) {
|
|
187
|
+
// 这一轮在后台继续;它之后的成败由该群自己的持久路径收尾,这里只留日志
|
|
188
|
+
settled.then(({ error }) => { if (error) log('say: ' + (error && error.message)) })
|
|
189
|
+
return first.admitted ? { ok: true, where: { state: 'started' } } : { ok: false, reason: 'unconfirmed' }
|
|
190
|
+
}
|
|
191
|
+
const { value, error } = first
|
|
192
|
+
if (value && value.duplicate) return { ok: true, where: { state: 'duplicate' } }
|
|
193
|
+
const state = receiptState(chatId, receiptId)
|
|
194
|
+
if (state) return { ok: true, where: { state: state === 'completed' ? 'done' : 'started' } }
|
|
195
|
+
if (error) {
|
|
196
|
+
log('say: ' + (error && error.message))
|
|
197
|
+
return { ok: false, reason: 'inject-failed' }
|
|
198
|
+
}
|
|
199
|
+
return { ok: false, reason: refusalOf(value) }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return async function say(chatId, text, shown, quiet, { requestId } = {}) {
|
|
203
|
+
const platform = getPlatform(chatId)
|
|
204
|
+
if (!platform || typeof platform.send !== 'function') return { ok: false, reason: 'no-daemon' }
|
|
205
|
+
const g = getGroup(chatId)
|
|
206
|
+
if (!g) return { ok: false, reason: 'unbound' }
|
|
207
|
+
if (g.paused) return { ok: false, reason: 'paused' }
|
|
208
|
+
const body = String(text || '')
|
|
209
|
+
if (!body.trim()) return { ok: false, reason: 'empty' }
|
|
210
|
+
if (!shown) return { ok: false, reason: 'silent-unsupported' }
|
|
211
|
+
if (quiet) return { ok: false, reason: 'quiet-unsupported' }
|
|
212
|
+
if (body.trim().startsWith('/')) return { ok: false, reason: 'command' }
|
|
213
|
+
let key
|
|
214
|
+
try { key = syntheticSource({ kind: 'console', chatId, requestId }).sourceId }
|
|
215
|
+
catch { return { ok: false, reason: 'request-id' } }
|
|
216
|
+
const prior = inflight.get(key)
|
|
217
|
+
const run = (prior ? prior.then(() => {}, () => {}) : Promise.resolve())
|
|
218
|
+
.then(() => attempt(platform, chatId, body, shown, key))
|
|
219
|
+
inflight.set(key, run)
|
|
220
|
+
const clear = () => { if (inflight.get(key) === run) inflight.delete(key) }
|
|
221
|
+
run.then(clear, clear)
|
|
222
|
+
try { return await run } catch (e) {
|
|
223
|
+
log('say: ' + (e && e.message))
|
|
224
|
+
return { ok: false, reason: 'inject-failed' }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { createConsoleSay, createDurableConsoleSay }
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { createHash } = require('node:crypto')
|
|
4
|
+
const { taskId } = require('./enginecheckpoint')
|
|
5
|
+
const { validatePersistentReceipts } = require('./c2persistrelation')
|
|
6
|
+
|
|
7
|
+
// Durable identity for the synthetic intake sources (operator console, board
|
|
8
|
+
// confirmation). A platform message carries its own stable ID; these sources
|
|
9
|
+
// do not, and `newSourceId()` deliberately mints a process-local `local:` ID
|
|
10
|
+
// that the durable C2 path refuses. This module derives an ID **only** from
|
|
11
|
+
// anchors that survive a restart:
|
|
12
|
+
//
|
|
13
|
+
// - the platform message the console line was actually posted as, or an
|
|
14
|
+
// operator-supplied idempotency key for a silent injection;
|
|
15
|
+
// - the board broadcast message plus the annotation digest of that round.
|
|
16
|
+
//
|
|
17
|
+
// Nothing here invents an identity from wall-clock time: a crash before the
|
|
18
|
+
// first durable receipt would then replay as a *different* job, which is the
|
|
19
|
+
// exact duplicate this contract exists to prevent. When no durable anchor
|
|
20
|
+
// exists the builder throws, and the caller must leave the receipt fields
|
|
21
|
+
// unset so the durable path keeps refusing the source instead of silently
|
|
22
|
+
// accepting an unreplayable one.
|
|
23
|
+
//
|
|
24
|
+
// No instruction text enters the ID: only bounded anchor IDs and a digest of
|
|
25
|
+
// the already-broadcast annotation list.
|
|
26
|
+
|
|
27
|
+
const HEX32 = /^[0-9a-f]{32}$/
|
|
28
|
+
const CHAT_ID = /^[A-Za-z0-9_:.-]{1,128}$/
|
|
29
|
+
const ANCHOR_ID = /^[A-Za-z0-9_:.-]{1,160}$/
|
|
30
|
+
const BOARD_KINDS = new Set(['apply', 'sync'])
|
|
31
|
+
const MAX_DIGEST_LINES = 512
|
|
32
|
+
const MAX_DIGEST_BYTES = 64 * 1024
|
|
33
|
+
|
|
34
|
+
function plain(value, label) {
|
|
35
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
36
|
+
Object.getPrototypeOf(value) !== Object.prototype ||
|
|
37
|
+
Reflect.ownKeys(value).some(key => typeof key !== 'string' ||
|
|
38
|
+
!Object.getOwnPropertyDescriptor(value, key)?.enumerable ||
|
|
39
|
+
!Object.hasOwn(Object.getOwnPropertyDescriptor(value, key), 'value'))) {
|
|
40
|
+
throw new TypeError(`${label} must be a plain data object`)
|
|
41
|
+
}
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function text(value, pattern, label) {
|
|
46
|
+
if (typeof value !== 'string' || !pattern.test(value)) {
|
|
47
|
+
throw new TypeError(`C2 synthetic ${label} invalid`)
|
|
48
|
+
}
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fingerprint(parts) {
|
|
53
|
+
const hash = createHash('sha256').update('sintbot-c2-synthetic-source-v1\0')
|
|
54
|
+
for (const part of parts) {
|
|
55
|
+
const bytes = Buffer.from(String(part), 'utf8')
|
|
56
|
+
hash.update(String(bytes.length)).update(':').update(bytes).update('\0')
|
|
57
|
+
}
|
|
58
|
+
return hash.digest('hex').slice(0, 32)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function digestFingerprint(lines) {
|
|
62
|
+
if (!Array.isArray(lines) || lines.length > MAX_DIGEST_LINES ||
|
|
63
|
+
Reflect.ownKeys(lines).length !== lines.length + 1 ||
|
|
64
|
+
lines.some(line => typeof line !== 'string')) {
|
|
65
|
+
throw new TypeError('C2 synthetic board digest invalid')
|
|
66
|
+
}
|
|
67
|
+
const joined = lines.join('\n')
|
|
68
|
+
if (Buffer.byteLength(joined) > MAX_DIGEST_BYTES) {
|
|
69
|
+
throw new TypeError('C2 synthetic board digest too large')
|
|
70
|
+
}
|
|
71
|
+
return fingerprint(['digest', String(lines.length), joined])
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// `console` with a visible line: the posted platform message is the anchor,
|
|
75
|
+
// so a replayed injection resolves to the same receipt as the first one.
|
|
76
|
+
// `console` silent injection: only an operator idempotency key can stand in;
|
|
77
|
+
// the console client mints it per submit, so its own retry deduplicates.
|
|
78
|
+
function consoleSourceId(descriptor) {
|
|
79
|
+
const chatId = text(descriptor.chatId, CHAT_ID, 'chat ID')
|
|
80
|
+
const shown = descriptor.shownMessageId
|
|
81
|
+
const request = descriptor.requestId
|
|
82
|
+
if (shown !== undefined && shown !== null) {
|
|
83
|
+
if (request !== undefined && request !== null) {
|
|
84
|
+
throw new TypeError('C2 synthetic console source takes one anchor')
|
|
85
|
+
}
|
|
86
|
+
text(shown, ANCHOR_ID, 'console anchor')
|
|
87
|
+
return `console:shown:${fingerprint(['console-shown', chatId, shown])}`
|
|
88
|
+
}
|
|
89
|
+
text(request, HEX32, 'console request key')
|
|
90
|
+
return `console:request:${fingerprint(['console-request', chatId, request])}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// `board`: the confirmation consumes one broadcast notice, and the round's
|
|
94
|
+
// work is exactly the digest that notice published. Both are durable, so a
|
|
95
|
+
// replay of the same confirmation maps onto the same receipt.
|
|
96
|
+
function boardSourceId(descriptor) {
|
|
97
|
+
const chatId = text(descriptor.chatId, CHAT_ID, 'chat ID')
|
|
98
|
+
const notice = text(descriptor.noticeMessageId, ANCHOR_ID, 'board anchor')
|
|
99
|
+
if (!BOARD_KINDS.has(descriptor.boardKind)) {
|
|
100
|
+
throw new TypeError('C2 synthetic board kind invalid')
|
|
101
|
+
}
|
|
102
|
+
const digest = digestFingerprint(descriptor.digestLines ?? [])
|
|
103
|
+
return `board:${descriptor.boardKind}:${fingerprint(['board', chatId, notice,
|
|
104
|
+
descriptor.boardKind, digest])}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const BUILDERS = { console: consoleSourceId, board: boardSourceId }
|
|
108
|
+
|
|
109
|
+
// Returns `{ source, sourceId }` for a synthetic intake, or throws when the
|
|
110
|
+
// descriptor has no durable anchor. Callers must not substitute a fallback
|
|
111
|
+
// ID of their own: a non-durable source has to stay visibly non-durable.
|
|
112
|
+
function syntheticSource(descriptor) {
|
|
113
|
+
plain(descriptor, 'C2 synthetic source descriptor')
|
|
114
|
+
const build = Object.hasOwn(BUILDERS, descriptor.kind) ? BUILDERS[descriptor.kind] : null
|
|
115
|
+
if (!build) throw new TypeError('C2 synthetic source kind unknown')
|
|
116
|
+
const sourceId = build(descriptor)
|
|
117
|
+
if (/^local:/i.test(sourceId) || Buffer.byteLength(sourceId) > 256) {
|
|
118
|
+
throw new Error('C2 synthetic source ID is not durable')
|
|
119
|
+
}
|
|
120
|
+
return Object.freeze({ source: descriptor.kind, sourceId })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The full pending-receipt shape for a synthetic intake. Validated with the
|
|
124
|
+
// same rules the durable journal applies, so an invalid identity fails here
|
|
125
|
+
// rather than at commit time.
|
|
126
|
+
function syntheticReceipt(descriptor, { acceptedAt, quiet } = {}) {
|
|
127
|
+
const { source, sourceId } = syntheticSource(descriptor)
|
|
128
|
+
if (!Number.isSafeInteger(acceptedAt) || acceptedAt <= 0) {
|
|
129
|
+
throw new TypeError('C2 synthetic receipt acceptedAt invalid')
|
|
130
|
+
}
|
|
131
|
+
if (typeof quiet !== 'boolean') throw new TypeError('C2 synthetic receipt quiet flag invalid')
|
|
132
|
+
const chatId = descriptor.chatId
|
|
133
|
+
const receipt = { id: taskId({ source, chatId, sourceId }), chatId, source, sourceId,
|
|
134
|
+
acceptedAt, quiet }
|
|
135
|
+
validatePersistentReceipts({ pending: [receipt], completed: [] })
|
|
136
|
+
return Object.freeze(receipt)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Direct cron effects have no model turn and therefore no turn receipt. This
|
|
140
|
+
// is the explicit disposition boundary for them, not a claim of idempotency.
|
|
141
|
+
//
|
|
142
|
+
// do → compensated: a durably qualified occurrence may be redispatched
|
|
143
|
+
// after a restart (see crononce.eligibleForRecovery).
|
|
144
|
+
// say → one outward text. cronfire re-sends only when the platform has
|
|
145
|
+
// *confirmed* non-delivery, at most four times, from in-process
|
|
146
|
+
// timers. An ambiguous outcome is never retried, and a restart drops
|
|
147
|
+
// the pending ladder: whatever was in flight is an operator call.
|
|
148
|
+
// run → a shell command whose side effects live outside sbot. Never
|
|
149
|
+
// rerun automatically, not even after a crash mid-run.
|
|
150
|
+
const DIRECT_EFFECT_POLICY = Object.freeze({
|
|
151
|
+
do: Object.freeze({ automatic: true, trigger: 'durable-eligibility',
|
|
152
|
+
maxAttempts: 1, restartDisposition: 'compensate' }),
|
|
153
|
+
say: Object.freeze({ automatic: true, trigger: 'confirmed-not-delivered',
|
|
154
|
+
maxAttempts: 4, restartDisposition: 'manual' }),
|
|
155
|
+
run: Object.freeze({ automatic: false, trigger: 'none',
|
|
156
|
+
maxAttempts: 1, restartDisposition: 'manual' }),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
function directEffectPolicy(kind) {
|
|
160
|
+
if (typeof kind !== 'string' || !Object.hasOwn(DIRECT_EFFECT_POLICY, kind)) {
|
|
161
|
+
throw new TypeError('C2 direct effect kind unknown')
|
|
162
|
+
}
|
|
163
|
+
return DIRECT_EFFECT_POLICY[kind]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { syntheticSource, syntheticReceipt, directEffectPolicy,
|
|
167
|
+
DIRECT_EFFECT_POLICY }
|
package/src/core/v2engine.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
// this class owns process-local payloads, runner handles and platform I/O.
|
|
3
3
|
// No legacy Engine, retry/failover scheduler or v1 reducer is loaded here.
|
|
4
4
|
'use strict'
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const path = require('node:path')
|
|
5
7
|
const { randomBytes } = require('node:crypto')
|
|
6
8
|
const { isDeepStrictEqual } = require('node:util')
|
|
7
9
|
const { redact } = require('./redact')
|
|
8
10
|
const { fmtStamp, normalizeTz } = require('./time')
|
|
9
|
-
const { settleUsage, manifestPolicy, manifestHeader, manifestContext,
|
|
11
|
+
const { settleUsage, manifestPolicy, manifestHeader, manifestContext, manifestFeatures, manifestSkills,
|
|
10
12
|
systemAppendix, turnPrompt, engineTerminalStep, engineQueueStep } = require('../agent/privatecore')
|
|
11
13
|
const { taskId } = require('./enginecheckpoint')
|
|
12
14
|
const { prepareCardTerminalPath } = require('./c2cardterminalpath')
|
|
@@ -171,6 +173,39 @@ function v2QueueSelection(queue) {
|
|
|
171
173
|
if (jobs.some(job => !job)) throw new Error('binary selected unknown queue handle')
|
|
172
174
|
return { jobs, anchor: effect.anchor.slice('receipt:job:'.length) }
|
|
173
175
|
}
|
|
176
|
+
|
|
177
|
+
// The group skills the release entry serves through `sbot feishu` (the same
|
|
178
|
+
// executable as `sintbot`): messages, notice cards, history, reply, edit,
|
|
179
|
+
// recall, download and reactions. Canvas, image generation and takeover cards
|
|
180
|
+
// are not in this release, and neither are scheduled tasks, so the cron
|
|
181
|
+
// section the core renders after the skills is cut off.
|
|
182
|
+
function releaseSkillLines(platform) {
|
|
183
|
+
const features = manifestFeatures({ ...(platform?.features || {}),
|
|
184
|
+
boards: false, imagen: false, takeoverCard: false })
|
|
185
|
+
const lines = manifestSkills({ features,
|
|
186
|
+
platformName: String(platform?.chatLabel || '飞书群').replace(/群$/, '') })
|
|
187
|
+
const cron = lines.findIndex(line => line.startsWith('【定时任务】'))
|
|
188
|
+
return cron < 0 ? lines : lines.slice(0, cron)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The group skills call `sbot feishu`. A machine can carry another `sbot` on
|
|
192
|
+
// PATH (an older global install), so a turn puts first the bin directory whose
|
|
193
|
+
// `sbot` is this very entry: an npm global prefix's bin next to
|
|
194
|
+
// lib/node_modules/sintbot. Nothing is changed when no such directory exists
|
|
195
|
+
// (a source checkout, tests).
|
|
196
|
+
let releaseBin
|
|
197
|
+
function releaseBinDir(entry = path.join(__dirname, '..', '..', 'bin', 'sintbot-v2.js')) {
|
|
198
|
+
if (releaseBin !== undefined && arguments.length === 0) return releaseBin
|
|
199
|
+
let found = null
|
|
200
|
+
try {
|
|
201
|
+
const self = fs.realpathSync(entry)
|
|
202
|
+
const dir = path.resolve(path.dirname(self), '..', '..', '..', '..', 'bin')
|
|
203
|
+
if (fs.realpathSync(path.join(dir, 'sbot')) === self) found = dir
|
|
204
|
+
} catch {}
|
|
205
|
+
if (arguments.length === 0) releaseBin = found
|
|
206
|
+
return found
|
|
207
|
+
}
|
|
208
|
+
|
|
174
209
|
class Engine {
|
|
175
210
|
constructor(cfg, options = {}) {
|
|
176
211
|
const { store, log = () => {}, durableV2Runtime, durableV2Payloads,
|
|
@@ -347,7 +382,9 @@ _rt(chatId) {
|
|
|
347
382
|
|
|
348
383
|
baseSkillEnv(chatId) {
|
|
349
384
|
const g = (this.cfg.groups || {})[chatId]
|
|
350
|
-
|
|
385
|
+
const bin = releaseBinDir()
|
|
386
|
+
return { ...(bin ? { PATH: [bin, process.env.PATH].filter(Boolean).join(path.delimiter) } : {}),
|
|
387
|
+
SBOT_CHAT_ID: chatId, ...(g && g.platform ? { SBOT_PLATFORM: g.platform } : {}), TZ: this.tz, ...(this.cfg._path ? { SBOT_CONFIG: this.cfg._path } : {}), ...(this.uiLocalUrl ? { SBOT_UI_URL: this.uiLocalUrl } : {}) }
|
|
351
388
|
}
|
|
352
389
|
|
|
353
390
|
_resolveGroup(chatId) {
|
|
@@ -2057,6 +2094,7 @@ classifyDurableV2UnresolvedTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
2057
2094
|
{ full: policy.full, isCodex: provider.runner === 'codex' }) : null,
|
|
2058
2095
|
unredactedPii: group.redactPii === false,
|
|
2059
2096
|
cardPinned: platform.features?.cardPinned === true, plainSend: false }),
|
|
2097
|
+
...releaseSkillLines(platform),
|
|
2060
2098
|
].join('\n')
|
|
2061
2099
|
// Reinject current supported context each turn. No legacy feature menu,
|
|
2062
2100
|
// external-message commands or v1 manifest refresh scheduler is included.
|
|
@@ -2146,4 +2184,4 @@ requestDurableV2Stop(chatId) {
|
|
|
2146
2184
|
|
|
2147
2185
|
}
|
|
2148
2186
|
|
|
2149
|
-
module.exports = { Engine }
|
|
2187
|
+
module.exports = { releaseBinDir, Engine }
|