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/feishu.js
CHANGED
|
@@ -32,7 +32,7 @@ function parseText(message) {
|
|
|
32
32
|
let c
|
|
33
33
|
try { c = JSON.parse(message.content) } catch { return null }
|
|
34
34
|
|
|
35
|
-
//
|
|
35
|
+
// 附件标记:图片/文件消息不再整条丢弃 —— 转成带下载坐标的文本标记喂给 agent
|
|
36
36
|
// (真机踩过:「我发给你的图里的那张表」,图被静默丢了,agent 无从知道有图)。标记里
|
|
37
37
|
// message_id + key 正是 `sbot feishu download` 要的两个参数,清单同步教这条藤怎么摸;
|
|
38
38
|
// 历史(fetchChatHistory)同一条解析线 ⇒ 群里翻旧图也拿得到坐标。
|
|
@@ -145,6 +145,31 @@ function assertCardOk(r, what = '卡片操作') {
|
|
|
145
145
|
const ERR_STREAM_DEAD = new Set([200850, 300309])
|
|
146
146
|
const STREAM_WINDOW_MS = 9 * 60 * 1000 // 10 分钟的窗口留 1 分钟余量(时钟漂移 + 在途请求)
|
|
147
147
|
|
|
148
|
+
// Opt-in seam for a future durable effect executor. Every call to invoke is
|
|
149
|
+
// exactly one SDK request; the boundary sees its stable operation ID, target,
|
|
150
|
+
// confirmed result or thrown error. It must persist intent before invoking.
|
|
151
|
+
// Without a boundary, all existing card behavior remains unchanged.
|
|
152
|
+
async function cardOperation(boundary, operationKey, kind, target, invoke) {
|
|
153
|
+
if (!boundary) return invoke()
|
|
154
|
+
if (typeof boundary !== 'function' || typeof operationKey !== 'string' ||
|
|
155
|
+
!operationKey || Buffer.byteLength(operationKey) > 256) {
|
|
156
|
+
throw new TypeError('card operation boundary needs a stable operation key')
|
|
157
|
+
}
|
|
158
|
+
let invoked = false
|
|
159
|
+
let settled = false
|
|
160
|
+
let invokeError = null
|
|
161
|
+
const result = await boundary({ operationId: `${operationKey}:${kind}${target.sequence == null ? '' : ':' + target.sequence}`,
|
|
162
|
+
kind, ...target, invoke: async () => {
|
|
163
|
+
if (invoked) throw new Error('card operation already invoked')
|
|
164
|
+
invoked = true
|
|
165
|
+
try { return await invoke() } catch (e) { invokeError = e; throw e }
|
|
166
|
+
finally { settled = true }
|
|
167
|
+
} })
|
|
168
|
+
if (invokeError) throw invokeError
|
|
169
|
+
if (!invoked || !settled) throw new Error('card operation boundary did not await one SDK call')
|
|
170
|
+
return result
|
|
171
|
+
}
|
|
172
|
+
|
|
148
173
|
// ── 卡片写入器(纯工厂:client / 时钟 / 窗口全可注入 ⇒ 可单测)─────────────────────
|
|
149
174
|
// 一张流式卡的**所有**写操作都从这里出去,好处是「往哪条路写」只剩一个判断点:
|
|
150
175
|
// 窗口内 → cardElement.content(有打字机动画)
|
|
@@ -155,6 +180,7 @@ const STREAM_WINDOW_MS = 9 * 60 * 1000 // 10 分钟的窗口留 1 分钟余量(
|
|
|
155
180
|
function createCardWriter({
|
|
156
181
|
client, cardId, elementId = 'answer', log = () => {}, now = () => Date.now(),
|
|
157
182
|
throttleMs = 0, streamWindowMs = STREAM_WINDOW_MS, setTimer = setTimeout, clearTimer = clearTimeout,
|
|
183
|
+
operationBoundary = null, operationKey = null,
|
|
158
184
|
} = {}) {
|
|
159
185
|
const openedAt = now()
|
|
160
186
|
let seq = 1 // 流式与整卡更新共用的**唯一**序号分配器(混用同一张卡必须同源)
|
|
@@ -170,30 +196,64 @@ function createCardWriter({
|
|
|
170
196
|
let fulls = 0 // 整卡更新 card.update
|
|
171
197
|
let closes = 0 // 关流式 card.settings
|
|
172
198
|
let lastSeen = '' // 最近一次收到的累积文本(判重用)
|
|
199
|
+
let confirmedText = null // 只记录平台确认过的正文,不能把已派发当成已送达
|
|
173
200
|
let chain = Promise.resolve()
|
|
174
|
-
|
|
201
|
+
let finishResult = null
|
|
202
|
+
let boundaryError = null
|
|
203
|
+
let updateRevision = 0
|
|
204
|
+
let flushedRevision = -1
|
|
205
|
+
const enqueue = (fn, { terminal = false } = {}) => {
|
|
206
|
+
const operation = chain.then(() => { if (boundaryError) throw boundaryError; return fn() })
|
|
207
|
+
chain = operation.catch((e) => {
|
|
208
|
+
log('feishu card op error: ' + (e && e.message))
|
|
209
|
+
if (operationBoundary) boundaryError = e
|
|
210
|
+
return null
|
|
211
|
+
})
|
|
212
|
+
return terminal ? (operationBoundary ? operation : operation.then((ack) => ack === true ? true : null, () => null)) : chain
|
|
213
|
+
}
|
|
214
|
+
const confirmedCardAck = (response, action) => {
|
|
215
|
+
assertCardOk(response, action)
|
|
216
|
+
if (!response || response.code !== 0) throw new Error(`${action}未确认:未返回 code=0`)
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
175
219
|
const expired = () => now() - openedAt >= streamWindowMs
|
|
176
220
|
|
|
177
221
|
// ⚠ 序号由上面那个分配器**统一发**,整卡更新不再自己取秒级时间戳 —— 服务端只比大小
|
|
178
222
|
// (倒退实测回 300317 sequence number compare failed),小整数完全够用;两条路各自取号
|
|
179
223
|
// 才是会撞的那种写法。
|
|
180
|
-
const writeBody = async (elements) => {
|
|
224
|
+
const writeBody = async (elements, boundary = operationBoundary) => {
|
|
181
225
|
fulls++
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
226
|
+
const sequence = seq++
|
|
227
|
+
await cardOperation(boundary, operationKey, 'card.update',
|
|
228
|
+
{ cardId, sequence }, async () => {
|
|
229
|
+
const response = await updateCardEntity(client, cardId, {
|
|
230
|
+
schema: '2.0',
|
|
231
|
+
config: { streaming_mode: false, summary: { content: '' } },
|
|
232
|
+
body: { elements },
|
|
233
|
+
}, sequence)
|
|
234
|
+
confirmedCardAck(response, '更新卡')
|
|
235
|
+
return response
|
|
236
|
+
})
|
|
187
237
|
streamOpen = false
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
const writeFull = async (text) => {
|
|
241
|
+
const ack = await writeBody([{ tag: 'markdown', content: text, element_id: elementId }])
|
|
242
|
+
confirmedText = text
|
|
243
|
+
return ack
|
|
188
244
|
}
|
|
189
|
-
const writeFull = (text) => writeBody([{ tag: 'markdown', content: text, element_id: elementId }])
|
|
190
245
|
|
|
191
246
|
const pushStream = async (text) => {
|
|
192
247
|
pushes++
|
|
193
|
-
|
|
248
|
+
const sequence = seq++
|
|
249
|
+
const uuid = crypto.randomUUID()
|
|
250
|
+
await cardOperation(operationBoundary, operationKey, 'cardElement.content',
|
|
251
|
+
{ cardId, elementId, sequence, uuid }, async () => confirmedCardAck(await client.cardkit.v1.cardElement.content({
|
|
194
252
|
path: { card_id: cardId, element_id: elementId },
|
|
195
|
-
data: { content: text, sequence
|
|
196
|
-
}), '流式推送')
|
|
253
|
+
data: { content: text, sequence, uuid },
|
|
254
|
+
}), '流式推送'))
|
|
255
|
+
confirmedText = text
|
|
256
|
+
return true
|
|
197
257
|
}
|
|
198
258
|
|
|
199
259
|
// 单一写入口。⚠ **只有「流式到期」那两个码才降级** —— 其它错误照抛(由 enqueue 记进
|
|
@@ -207,7 +267,7 @@ function createCardWriter({
|
|
|
207
267
|
}
|
|
208
268
|
if (streamOpen) {
|
|
209
269
|
try { return await pushStream(text) } catch (e) {
|
|
210
|
-
if (!ERR_STREAM_DEAD.has(e.code)) throw e
|
|
270
|
+
if (operationBoundary || !ERR_STREAM_DEAD.has(e.code)) throw e
|
|
211
271
|
log(`feishu card: 流式已到期(${e.code}),改走整卡更新`)
|
|
212
272
|
streamOpen = false
|
|
213
273
|
}
|
|
@@ -216,15 +276,19 @@ function createCardWriter({
|
|
|
216
276
|
}
|
|
217
277
|
|
|
218
278
|
const closeStreaming = async () => {
|
|
219
|
-
if (!streamOpen) return
|
|
220
|
-
streamOpen = false
|
|
279
|
+
if (!streamOpen) return true
|
|
221
280
|
closes++
|
|
281
|
+
const sequence = seq++
|
|
282
|
+
const uuid = crypto.randomUUID()
|
|
222
283
|
try {
|
|
223
|
-
|
|
284
|
+
await cardOperation(operationBoundary, operationKey, 'card.settings',
|
|
285
|
+
{ cardId, sequence, uuid }, async () => confirmedCardAck(await client.cardkit.v1.card.settings({
|
|
224
286
|
path: { card_id: cardId },
|
|
225
|
-
data: { settings: JSON.stringify({ config: { streaming_mode: false } }), sequence
|
|
226
|
-
}), '关流式')
|
|
227
|
-
} catch (e) { if (!ERR_STREAM_DEAD.has(e.code)) throw e } // 已经自动关了 = 目的已达成
|
|
287
|
+
data: { settings: JSON.stringify({ config: { streaming_mode: false } }), sequence, uuid },
|
|
288
|
+
}), '关流式'))
|
|
289
|
+
} catch (e) { if (operationBoundary || !ERR_STREAM_DEAD.has(e.code)) throw e } // 已经自动关了 = 目的已达成
|
|
290
|
+
streamOpen = false
|
|
291
|
+
return true
|
|
228
292
|
}
|
|
229
293
|
|
|
230
294
|
const throttle = createCardThrottle({ intervalMs: throttleMs, send: (t) => enqueue(() => write(t)), now, setTimer, clearTimer })
|
|
@@ -236,38 +300,77 @@ function createCardWriter({
|
|
|
236
300
|
const t = String(text || '')
|
|
237
301
|
if (!t || t === lastSeen) return
|
|
238
302
|
lastSeen = t
|
|
303
|
+
updateRevision++
|
|
304
|
+
flushedRevision = -1
|
|
239
305
|
throttle.push(t)
|
|
240
306
|
},
|
|
307
|
+
// Durable v2 callers use this before a terminal reply attempt. Drain a
|
|
308
|
+
// throttled trailing frame and wait for every earlier SDK operation. A
|
|
309
|
+
// concurrent update invalidates the proof, so finish must flush again.
|
|
310
|
+
async flush() {
|
|
311
|
+
if (!operationBoundary || done || frozen) {
|
|
312
|
+
throw new Error('durable card flush needs an open operation boundary')
|
|
313
|
+
}
|
|
314
|
+
const revision = updateRevision
|
|
315
|
+
const trailing = throttle.drain()
|
|
316
|
+
if (trailing !== null) enqueue(() => write(trailing))
|
|
317
|
+
await chain
|
|
318
|
+
if (boundaryError) throw boundaryError
|
|
319
|
+
if (done || frozen || updateRevision !== revision) {
|
|
320
|
+
throw new Error('durable card changed during flush')
|
|
321
|
+
}
|
|
322
|
+
flushedRevision = revision
|
|
323
|
+
return true
|
|
324
|
+
},
|
|
241
325
|
// buildElements(t):答案含表格时给出整卡元素(表格精致化);返回 null ⇒ 普通 markdown。
|
|
242
|
-
finish(text, { buildElements = () => null } = {}) {
|
|
243
|
-
if (done) return
|
|
326
|
+
finish(text, { buildElements = () => null, terminalOperationBoundary } = {}) {
|
|
327
|
+
if (done) return finishResult
|
|
328
|
+
const terminal = terminalOperationBoundary !== undefined
|
|
329
|
+
if (terminal && (!operationBoundary || typeof terminalOperationBoundary !== 'function' ||
|
|
330
|
+
flushedRevision !== updateRevision || frozen ||
|
|
331
|
+
typeof text !== 'string' || !text.trim())) {
|
|
332
|
+
throw new Error('durable card terminal needs flushed nonempty text and one boundary')
|
|
333
|
+
}
|
|
244
334
|
done = true
|
|
245
335
|
const leftover = throttle.drain() // 取消挂起帧,拿回还没推的文本
|
|
246
336
|
const t = String(text || '') || leftover || ''
|
|
247
337
|
let els = null
|
|
248
338
|
try { els = buildElements(t) } catch { /* 构建失败按无表格处理 */ }
|
|
249
|
-
|
|
250
|
-
return enqueue(async () => {
|
|
339
|
+
finishResult = enqueue(async () => {
|
|
251
340
|
try {
|
|
341
|
+
if (terminal) {
|
|
342
|
+
// The outer finalReply effect owns this one SDK call. A final
|
|
343
|
+
// card.update also closes streaming mode; no card.settings or
|
|
344
|
+
// second outbound attempt may be nested under that effect.
|
|
345
|
+
const elements = els || [{ tag: 'markdown', content: t, element_id: elementId }]
|
|
346
|
+
const ack = await writeBody(elements, terminalOperationBoundary)
|
|
347
|
+
confirmedText = t
|
|
348
|
+
return ack
|
|
349
|
+
}
|
|
252
350
|
if (els) {
|
|
253
351
|
// 表格被拒就退普通 markdown(宁可松散不可丢内容)
|
|
254
|
-
try {
|
|
352
|
+
try { const ack = await writeBody(els); confirmedText = t; return ack }
|
|
353
|
+
catch (e) {
|
|
354
|
+
if (operationBoundary) throw e
|
|
355
|
+
log('feishu card table render fallback: ' + (e && e.message))
|
|
356
|
+
}
|
|
255
357
|
}
|
|
256
358
|
// ⭐ 有正文一律整卡更新落地;没有新正文才只关流式 —— **绝不拿空正文去刷卡**,
|
|
257
359
|
// 那会把已经推上去的内容抹掉(「ack 后转静默」走的正是空正文这条)。
|
|
258
|
-
if (
|
|
259
|
-
await closeStreaming()
|
|
360
|
+
if (t && t !== confirmedText) return await writeFull(t)
|
|
361
|
+
return await closeStreaming()
|
|
260
362
|
} finally {
|
|
261
363
|
// ⚠ 这行必须打在**收尾之后**:收尾自己那一次调用(整卡更新 / 关流式)就发生在这个
|
|
262
364
|
// 回调里。原先它打在回调外、且只报节流器的派发次数 ⇒ 降级后的整卡更新与收尾那次
|
|
263
365
|
// 全在账外(实测漏计 ≈44 次/天)。注释写着「配额账可实测」,那就得真能对上账。
|
|
264
366
|
log(`card ops: ${pushes + fulls + closes} 次调用(流式 ${pushes} · 整卡 ${fulls} · 关流式 ${closes})`)
|
|
265
367
|
}
|
|
266
|
-
})
|
|
368
|
+
}, { terminal: true })
|
|
369
|
+
return finishResult
|
|
267
370
|
},
|
|
268
371
|
// recall:先断掉后续流式帧再去删消息。⚠ 只冻结不收尾 —— 撤不掉时调用方还要退一句中性收尾。
|
|
269
|
-
markClosed() { frozen = true },
|
|
270
|
-
settle: () => chain,
|
|
372
|
+
markClosed() { frozen = true; flushedRevision = -1 },
|
|
373
|
+
settle: () => chain.then((result) => { if (boundaryError) throw boundaryError; return result }),
|
|
271
374
|
stats: () => ({ pushes, fullUpdates: fulls, closes, calls: pushes + fulls + closes, streamOpen, seq }),
|
|
272
375
|
}
|
|
273
376
|
}
|
|
@@ -285,7 +388,7 @@ function createCardWriter({
|
|
|
285
388
|
* card — 流式卡片调优:throttleMs(推送节流窗,默认 1500;0 = 逐帧推)
|
|
286
389
|
* @returns {import('../core/interfaces').Platform}
|
|
287
390
|
*/
|
|
288
|
-
// ──
|
|
391
|
+
// ── 回答卡表格精致化──────────────────────────────────────────────
|
|
289
392
|
// markdown 表格在卡片里被客户端渲染成分页控件:行高松散、长列截断还要横向滚动
|
|
290
393
|
// (2026-08-04 群里真实反馈「显示不全、不紧凑、不精致」)。原生 table 组件能钉住这些:
|
|
291
394
|
// 紧凑行高(low)+ 固定列宽 + 灰底表头。列宽策略出自真机探针逐张对比、operator 拍板:
|
|
@@ -344,7 +447,7 @@ function tableElement(seg) {
|
|
|
344
447
|
}
|
|
345
448
|
}
|
|
346
449
|
|
|
347
|
-
//
|
|
450
|
+
// 可复制版折叠区:原生 table 好看但手机端选不中、整卡转发也带不走表格内容(飞书平台
|
|
348
451
|
// 限制,卡片 config 无「可复制」开关)。v0.11.4 的解法是每次都**另发一条**纯文本 —— 真机 14 小时
|
|
349
452
|
// 里 4 次,其中 2 次卡片内已有同样的表格,纯属重复,群里嫌累赘(2026-08-06 用户提)。
|
|
350
453
|
// 现在收进卡片内一个**默认收起**的折叠面板:不要的人完全看不见,要复制的人展开即得,群里不再
|
|
@@ -370,7 +473,7 @@ function copyPanel(tsv) {
|
|
|
370
473
|
}
|
|
371
474
|
}
|
|
372
475
|
|
|
373
|
-
//
|
|
476
|
+
// 「过程」折叠区:引擎从本轮正文里认出的里程碑行(`>> …`,见 engine milestonesOf),收尾
|
|
374
477
|
// 时整批交到这里。默认收起 —— 卡片仍然只露答案(v0.11.11 的「简」不破),要看做了哪几步的人展开即得;
|
|
375
478
|
// 中止 / 出错时它就是「做到哪了」。形状与 copyPanel 同款(同一组已被服务端接受的字段)。
|
|
376
479
|
function milestonePanel(lines) {
|
|
@@ -396,13 +499,14 @@ function milestonePanel(lines) {
|
|
|
396
499
|
// 三样都没有 ⇒ null(调用方走原 markdown 收尾,零变化)。
|
|
397
500
|
// footer(2026-09-16 用户提):耗时与 tokens 一行,引擎拼好文字、这里只管形状 —— 与生图进度卡 /
|
|
398
501
|
// 接管卡的小字同款(text_size notation + 灰字;note 组件在 schema 2.0 已被拒,见 memory 卡片全集)。
|
|
399
|
-
function answerCardElements(text, { milestones = [], footer = '' } = {}) {
|
|
502
|
+
function answerCardElements(text, { milestones = [], footer = '', footerDetail = '' } = {}) {
|
|
400
503
|
const segs = scanTables(text)
|
|
401
504
|
const tables = segs.filter((s) => s.kind === 'table')
|
|
402
505
|
const ms = (Array.isArray(milestones) ? milestones : []).filter((m) => typeof m === 'string' && m.trim())
|
|
403
506
|
const ft = typeof footer === 'string' ? footer.trim() : ''
|
|
507
|
+
const ftDetail = typeof footerDetail === 'string' ? footerDetail.trim() : ''
|
|
404
508
|
const nativeTables = tables.length > 0 && tables.length <= 5
|
|
405
|
-
if (!nativeTables && !ms.length && !ft) return null
|
|
509
|
+
if (!nativeTables && !ms.length && !ft && !ftDetail) return null
|
|
406
510
|
const elements = []
|
|
407
511
|
if (nativeTables) {
|
|
408
512
|
for (const seg of segs) {
|
|
@@ -424,18 +528,24 @@ function answerCardElements(text, { milestones = [], footer = '' } = {}) {
|
|
|
424
528
|
// ⚠ 顺序钉死:正文 → 可复制版 → 过程 → 小字。小字是整卡最底、右对齐(2026-09-16 定下的位置:
|
|
425
529
|
// 「小号字体、卡片右下角」);notation = 12px,是 markdown 组件最小的一档,右对齐靠 text_align。
|
|
426
530
|
if (ft) elements.push({ tag: 'markdown', text_size: 'notation', text_align: 'right', content: `<font color='grey'>${ft}</font>` })
|
|
531
|
+
// 型号来自可配置字段,不能直接插进 font 标签;把第二行独立成元素,手机宽度不足时自然换行。
|
|
532
|
+
if (ftDetail) {
|
|
533
|
+
const safe = ftDetail.replace(/[\r\n]+/g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
534
|
+
elements.push({ tag: 'markdown', text_size: 'notation', text_align: 'right', content: `<font color='grey'>${safe}</font>` })
|
|
535
|
+
}
|
|
427
536
|
return elements
|
|
428
537
|
}
|
|
429
538
|
|
|
430
|
-
function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMentioned = false, log = () => {}, onConn = () => {}, ws = {}, card = {} } = {}) {
|
|
539
|
+
function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMentioned = false, log = () => {}, onConn = () => {}, ws = {}, card = {}, client: suppliedClient = null } = {}) {
|
|
431
540
|
if (!appId || !appSecret) throw new Error('feishu: appId and appSecret required')
|
|
432
541
|
|
|
433
|
-
const client = new lark.Client({ appId, appSecret, loggerLevel: lark.LoggerLevel.warn })
|
|
542
|
+
const client = suppliedClient || new lark.Client({ appId, appSecret, loggerLevel: lark.LoggerLevel.warn })
|
|
434
543
|
// 流式卡片推送节流窗(ms)。缺省 1500 = 每 1.5s 最多刷一次卡;显式 0 ⇒ 逐帧推(旧行为)。
|
|
435
544
|
const cardThrottleMs = Number(card.throttleMs) === 0 ? 0 : Math.max(0, Number(card.throttleMs) || 1500)
|
|
436
545
|
let wsClient = null
|
|
437
546
|
let watchdog = null
|
|
438
547
|
let tickTimer = null
|
|
548
|
+
let serviceIntakeClosed = true
|
|
439
549
|
|
|
440
550
|
// WS liveness tuning — see wswatchdog.js. All configurable via cfg.feishu.ws so the
|
|
441
551
|
// detection策略 can be tuned on the real machine without a code change. pingTimeout is the KEY knob: the SDK's pong-watchdog is off by
|
|
@@ -482,9 +592,12 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
482
592
|
params: { receive_id_type: 'chat_id' },
|
|
483
593
|
data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text: content }) },
|
|
484
594
|
})
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
595
|
+
// SDK 对 HTTP 200 + 业务 code≠0 不抛。只有 code===0 且有消息 ID
|
|
596
|
+
// 才能把主动发送当作已确认;调用方据此决定是否结算。
|
|
597
|
+
if (!r || r.code !== 0) { log('feishu send rejected: code=' + (r && r.code)); return null }
|
|
598
|
+
const messageId = (r.data && r.data.message_id) || r.message_id || null
|
|
599
|
+
if (!messageId) log('feishu send rejected: missing message_id')
|
|
600
|
+
return messageId
|
|
488
601
|
} catch (e) {
|
|
489
602
|
log('feishu send error: ' + larkErrMsg(e))
|
|
490
603
|
return null
|
|
@@ -492,8 +605,34 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
492
605
|
}
|
|
493
606
|
|
|
494
607
|
return {
|
|
608
|
+
features: { cardQuota: true, noticeCard: true }, // 飞书支持通知卡;企微/Slack 退回文本
|
|
495
609
|
async start(onMessage, hooks = {}) {
|
|
496
610
|
if (!botOpenId && !onlyWhenMentioned) log('warning: feishu.botOpenId not set — cannot tell @bot from @others; all group messages will be handled')
|
|
611
|
+
if (hooks.onFatal !== undefined && typeof hooks.onFatal !== 'function') {
|
|
612
|
+
throw new TypeError('feishu fatal intake hook must be a function')
|
|
613
|
+
}
|
|
614
|
+
serviceIntakeClosed = false
|
|
615
|
+
let intakeClosed = false
|
|
616
|
+
const closeFatalIntake = (error) => {
|
|
617
|
+
if (!hooks.onFatal || intakeClosed) return
|
|
618
|
+
// This is the opt-in durable ingress boundary: a failed receipt/lock
|
|
619
|
+
// operation cannot be reduced to a log line and followed by more input.
|
|
620
|
+
intakeClosed = true
|
|
621
|
+
serviceIntakeClosed = true
|
|
622
|
+
if (tickTimer) { clearInterval(tickTimer); tickTimer = null }
|
|
623
|
+
if (watchdog) watchdog.stop()
|
|
624
|
+
try { if (wsClient?.close) wsClient.close({ force: true }) } catch {}
|
|
625
|
+
try {
|
|
626
|
+
Promise.resolve(hooks.onFatal(error)).catch(e =>
|
|
627
|
+
log('feishu fatal intake hook rejected: ' + (e && e.message)))
|
|
628
|
+
} catch (e) {
|
|
629
|
+
log('feishu fatal intake hook threw: ' + (e && e.message))
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const reportOnMessageFailure = error => {
|
|
633
|
+
log('feishu onMessage error: ' + (error && error.message))
|
|
634
|
+
closeFatalIntake(error)
|
|
635
|
+
}
|
|
497
636
|
|
|
498
637
|
const dispatcher = new lark.EventDispatcher({}).register({
|
|
499
638
|
// 撤回即停:用户撤回了发给 bot 的消息 ⇒ 引擎跳过/中止对它的处理(排队剔除,运行中如 /stop)。
|
|
@@ -502,6 +641,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
502
641
|
// no-handle 警告实锤在推),注册 handler 即收,不需要发版。⚠ payload 不带 chat_id —— 匹配
|
|
503
642
|
// 只靠 message_id(播报消息 id 全局唯一,足够)。
|
|
504
643
|
'im.message.reaction.created_v1': async (data) => {
|
|
644
|
+
if (serviceIntakeClosed) return
|
|
505
645
|
if (watchdog) watchdog.onEvent()
|
|
506
646
|
try {
|
|
507
647
|
const ev = (data && data.event) || data || {}
|
|
@@ -532,6 +672,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
532
672
|
},
|
|
533
673
|
// 需在开放平台补订阅 im.message.recalled_v1(与 receive_v1 同权限);未订阅则事件不来,零回归。
|
|
534
674
|
'im.message.recalled_v1': async (data) => {
|
|
675
|
+
if (serviceIntakeClosed) return
|
|
535
676
|
if (watchdog) watchdog.onEvent() // 任何入站事件都是链路活性信号
|
|
536
677
|
try {
|
|
537
678
|
const ev = (data && data.event) || data || {}
|
|
@@ -551,6 +692,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
551
692
|
// (subscribeBoard),后台还须订阅事件类型 + 发版;未订阅零回归。纪律同 recall:到达即
|
|
552
693
|
// 打日志、不在 handler 里 await 慢查询。
|
|
553
694
|
'board.whiteboard.updated_v1': async (data) => {
|
|
695
|
+
if (serviceIntakeClosed) return
|
|
554
696
|
if (watchdog) watchdog.onEvent()
|
|
555
697
|
try {
|
|
556
698
|
const ev = (data && data.event) || data || {}
|
|
@@ -567,6 +709,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
567
709
|
}
|
|
568
710
|
},
|
|
569
711
|
'im.message.receive_v1': async (data) => {
|
|
712
|
+
if (intakeClosed || serviceIntakeClosed) return
|
|
570
713
|
if (watchdog) watchdog.onEvent() // any inbound event ⇒ link is alive (liveness signal)
|
|
571
714
|
try {
|
|
572
715
|
const ev = (data && data.event) || data || {}
|
|
@@ -597,7 +740,8 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
597
740
|
// Fire-and-forget into the engine (NFR1): a turn can run for minutes,
|
|
598
741
|
// and whether the SDK serializes pending handlers is not contractual —
|
|
599
742
|
// message reception must never block on turn completion.
|
|
600
|
-
|
|
743
|
+
let result
|
|
744
|
+
try { result = onMessage({
|
|
601
745
|
chatId: message.chat_id,
|
|
602
746
|
userId: openId || 'unknown',
|
|
603
747
|
senderName,
|
|
@@ -605,9 +749,11 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
605
749
|
msgId: message.message_id,
|
|
606
750
|
createTimeMs: Number(message.create_time) || Date.now(),
|
|
607
751
|
replyCtx: { chatId: message.chat_id, messageId: message.message_id },
|
|
608
|
-
})
|
|
752
|
+
}) } catch (error) { reportOnMessageFailure(error); return }
|
|
753
|
+
Promise.resolve(result).catch(reportOnMessageFailure)
|
|
609
754
|
} catch (e) {
|
|
610
755
|
log('feishu event error: ' + (e && e.message))
|
|
756
|
+
closeFatalIntake(e)
|
|
611
757
|
}
|
|
612
758
|
},
|
|
613
759
|
})
|
|
@@ -669,7 +815,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
669
815
|
|
|
670
816
|
async reply(replyCtx, content) {
|
|
671
817
|
const mid = replyCtx && replyCtx.messageId
|
|
672
|
-
//
|
|
818
|
+
// 挂到触发它的那条消息下:群里多人交叉说话时,独立发一条看不出这句在回应谁。
|
|
673
819
|
// 回复失败(原消息已撤回/超期)⇒ 退回直接发 —— 形态是锦上添花,把消息弄丢才是事故。
|
|
674
820
|
if (mid && content) {
|
|
675
821
|
try {
|
|
@@ -682,6 +828,20 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
682
828
|
return sendText(replyCtx && replyCtx.chatId, content)
|
|
683
829
|
},
|
|
684
830
|
|
|
831
|
+
// Durable terminal effects already record one attempt before this call.
|
|
832
|
+
// Return the raw SDK outcome so the caller can require a message ID;
|
|
833
|
+
// never fall back to a second send when this attempt is ambiguous.
|
|
834
|
+
async replyOnce(replyCtx, content) {
|
|
835
|
+
const mid = replyCtx && replyCtx.messageId
|
|
836
|
+
if (typeof mid !== 'string' || !/^om_[A-Za-z0-9_-]{1,124}$/.test(mid) ||
|
|
837
|
+
typeof replyCtx.chatId !== 'string' || !replyCtx.chatId ||
|
|
838
|
+
typeof content !== 'string' || !content) {
|
|
839
|
+
throw new TypeError('durable Feishu reply needs exact message target and text')
|
|
840
|
+
}
|
|
841
|
+
return client.im.message.reply({ path: { message_id: mid },
|
|
842
|
+
data: { msg_type: 'text', content: JSON.stringify({ text: content }) } })
|
|
843
|
+
},
|
|
844
|
+
|
|
685
845
|
async send(chatId, content) {
|
|
686
846
|
return sendText(chatId, content)
|
|
687
847
|
},
|
|
@@ -697,9 +857,15 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
697
857
|
// old content is always a prefix of the new, the client renders a typewriter
|
|
698
858
|
// effect. `finish` flips streaming_mode off so the card settles.
|
|
699
859
|
// All ops are serialized so the per-card `sequence` stays strictly increasing.
|
|
700
|
-
async createStreamingCard(replyCtx, { title = 'sbot'
|
|
860
|
+
async createStreamingCard(replyCtx, { title = 'sbot', operationBoundary = null,
|
|
861
|
+
operationKey = null } = {}) {
|
|
701
862
|
const chatId = replyCtx && replyCtx.chatId
|
|
702
863
|
if (!chatId) return null
|
|
864
|
+
if (operationBoundary && (typeof operationBoundary !== 'function' ||
|
|
865
|
+
typeof operationKey !== 'string' || !operationKey ||
|
|
866
|
+
Buffer.byteLength(operationKey) > 256)) {
|
|
867
|
+
throw new TypeError('card operation boundary needs a stable operation key')
|
|
868
|
+
}
|
|
703
869
|
const ELEM = 'answer'
|
|
704
870
|
// No header — the reply card shows just the answer body (no "sbot" title line).
|
|
705
871
|
const cardJson = {
|
|
@@ -709,54 +875,120 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
709
875
|
}
|
|
710
876
|
|
|
711
877
|
let cardId
|
|
712
|
-
let msgId = null //
|
|
878
|
+
let msgId = null // 静默收尾时撤回这张卡要用;投递结果里带回来,别丢在地上
|
|
713
879
|
try {
|
|
714
880
|
// ⚠ SDK 对业务错误(HTTP 200 + code≠0)不抛 —— 不查 code 的话,建卡被拒时这里拿到的是
|
|
715
881
|
// 空 card_id,拿着它去投递会 400,**真实的 cardkit 拒绝理由就被那个 400 盖掉了**
|
|
716
882
|
// (排查时只看得见「消息发送失败」)。sendChatCard 早修过同一处,写法照抄。
|
|
717
|
-
const created =
|
|
883
|
+
const created = await cardOperation(operationBoundary, operationKey, 'card.create',
|
|
884
|
+
{ chatId, replyToMsgId: replyCtx && replyCtx.messageId || null },
|
|
885
|
+
async () => {
|
|
886
|
+
const response = assertCardOk(await client.cardkit.v1.card.create({
|
|
887
|
+
data: { type: 'card_json', data: JSON.stringify(cardJson) },
|
|
888
|
+
}), '建卡')
|
|
889
|
+
if (operationBoundary && response?.code !== 0) {
|
|
890
|
+
throw new Error('建卡结果未确认:未返回 code=0')
|
|
891
|
+
}
|
|
892
|
+
if (!response?.data?.card_id) throw new Error('建卡被拒:未返回 card_id')
|
|
893
|
+
return response
|
|
894
|
+
})
|
|
718
895
|
cardId = created && created.data && created.data.card_id
|
|
719
896
|
if (!cardId) throw new Error('建卡被拒:未返回 card_id')
|
|
720
|
-
//
|
|
897
|
+
// 挂到触发它的那条消息下:群里多人交叉说话时,独立发一条卡看不出在回应谁。
|
|
721
898
|
// 排队合并批次的锚点是最新那条(见 _coalesce)。回复形态的卡照常流式更新(真机探针验过)。
|
|
722
899
|
// 拿不到消息 id(合成消息)或回复失败(原消息已被撤回)⇒ 退回直接发,绝不因此丢答案。
|
|
723
|
-
const posted = await postCard(client, chatId, replyCtx && replyCtx.messageId, cardId
|
|
900
|
+
const posted = await postCard(client, chatId, replyCtx && replyCtx.messageId, cardId,
|
|
901
|
+
{ operationBoundary, operationKey })
|
|
724
902
|
msgId = posted
|
|
725
903
|
} catch (e) {
|
|
726
904
|
log('feishu card create error: ' + (e && e.message))
|
|
905
|
+
if (operationBoundary) throw e
|
|
727
906
|
return null // caller falls back to a plain-text reply
|
|
728
907
|
}
|
|
729
908
|
|
|
730
909
|
// 全部写操作交给写入器(流式窗口判断、序号分配、错误码检查都在它一处)。
|
|
731
|
-
const writer = createCardWriter({ client, cardId, elementId: ELEM, log,
|
|
910
|
+
const writer = createCardWriter({ client, cardId, elementId: ELEM, log,
|
|
911
|
+
throttleMs: cardThrottleMs, operationBoundary, operationKey })
|
|
732
912
|
|
|
733
913
|
return {
|
|
734
|
-
|
|
914
|
+
durableTarget() {
|
|
915
|
+
if (!operationBoundary || !cardId || !msgId) {
|
|
916
|
+
throw new Error('durable card target unavailable')
|
|
917
|
+
}
|
|
918
|
+
return Object.freeze({ chatId, cardId, msgId })
|
|
919
|
+
},
|
|
920
|
+
// 撤回整张卡(ack 或过程文字之后该轮却以静默收场时用)。scope 零新增 —— send_as_bot 本就
|
|
735
921
|
// 覆盖撤回自己发的消息。先把在途的卡片操作排干,免得撤完又被补一帧打回来。
|
|
736
922
|
async recall() {
|
|
737
923
|
if (!msgId) return false
|
|
738
924
|
writer.markClosed()
|
|
739
|
-
try { await writer.settle() } catch {
|
|
740
|
-
|
|
741
|
-
|
|
925
|
+
try { await writer.settle() } catch (e) {
|
|
926
|
+
if (operationBoundary) throw e
|
|
927
|
+
/* 在途操作失败无所谓,照撤 */
|
|
928
|
+
}
|
|
929
|
+
try {
|
|
930
|
+
await cardOperation(operationBoundary, operationKey, 'message.delete',
|
|
931
|
+
{ chatId, cardId, msgId }, async () => {
|
|
932
|
+
const response = assertCardOk(await client.im.message.delete({
|
|
933
|
+
path: { message_id: msgId },
|
|
934
|
+
}), '撤卡')
|
|
935
|
+
if (operationBoundary && response?.code !== 0) {
|
|
936
|
+
throw new Error('撤卡结果未确认:未返回 code=0')
|
|
937
|
+
}
|
|
938
|
+
return response
|
|
939
|
+
})
|
|
940
|
+
return true
|
|
941
|
+
} catch (e) {
|
|
942
|
+
log('feishu card recall error: ' + (e && e.message))
|
|
943
|
+
if (operationBoundary) throw e
|
|
944
|
+
return false
|
|
945
|
+
}
|
|
742
946
|
},
|
|
743
947
|
// push the FULL text so far (cumulative). No-ops if unchanged (hash-skip).
|
|
744
948
|
update(text) { writer.update(text) },
|
|
745
|
-
//
|
|
949
|
+
// Opt-in durable terminal handoff: all earlier card operations must
|
|
950
|
+
// finish before the terminal effect begins its own external attempt.
|
|
951
|
+
flush() { return writer.flush() },
|
|
952
|
+
// 收尾:把最终正文落地并关掉流式。表格精致化= 答案含表格时整卡更新成
|
|
746
953
|
// 「markdown 段 + 原生表格组件」;无表格也走整卡更新(流式那条路会在第 10 分钟失效)。
|
|
747
|
-
// opts.milestones:引擎认出的 `>> ` 里程碑,收进「过程」折叠区
|
|
954
|
+
// opts.milestones:引擎认出的 `>> ` 里程碑,收进「过程」折叠区
|
|
748
955
|
// opts.footer:耗时与 tokens 一行小字,挂在整卡最底(2026-09-16)
|
|
749
|
-
finish(text, opts = {}) { return writer.finish(text, {
|
|
956
|
+
finish(text, opts = {}) { return writer.finish(text, {
|
|
957
|
+
buildElements: (t) => answerCardElements(t, opts),
|
|
958
|
+
...(Object.hasOwn(opts, 'terminalOperationBoundary')
|
|
959
|
+
? { terminalOperationBoundary: opts.terminalOperationBoundary } : {}),
|
|
960
|
+
}) },
|
|
750
961
|
}
|
|
751
962
|
},
|
|
752
963
|
|
|
753
|
-
async stop() {
|
|
964
|
+
async stop({ strict = false } = {}) {
|
|
754
965
|
// Stop the watchdog + tick first so a mid-shutdown state change can't trigger
|
|
755
966
|
// a rebuild, then close the socket cleanly (close({force}) is the SDK's
|
|
756
967
|
// documented teardown — kills timers + the reconnect loop, no orphan sockets).
|
|
968
|
+
if (typeof strict !== 'boolean') throw new TypeError('feishu stop strict must be boolean')
|
|
969
|
+
serviceIntakeClosed = true
|
|
757
970
|
if (tickTimer) { try { clearInterval(tickTimer) } catch {} tickTimer = null }
|
|
758
|
-
if (watchdog) {
|
|
759
|
-
|
|
971
|
+
if (watchdog) {
|
|
972
|
+
if (strict) watchdog.stop()
|
|
973
|
+
else try { watchdog.stop() } catch {}
|
|
974
|
+
}
|
|
975
|
+
const closing = wsClient
|
|
976
|
+
if (strict) {
|
|
977
|
+
if (closing) {
|
|
978
|
+
if (typeof closing.close !== 'function' ||
|
|
979
|
+
typeof closing.getConnectionStatus !== 'function') {
|
|
980
|
+
throw new Error('feishu strict stop cannot verify WS closure')
|
|
981
|
+
}
|
|
982
|
+
await closing.close({ force: true })
|
|
983
|
+
const state = closing.getConnectionStatus()?.state
|
|
984
|
+
if (state !== 'idle') {
|
|
985
|
+
throw new Error('feishu strict stop WS remains connected or unverified')
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
wsClient = null
|
|
989
|
+
return true
|
|
990
|
+
}
|
|
991
|
+
try { if (closing?.close) await closing.close({ force: true }) } catch {}
|
|
760
992
|
wsClient = null
|
|
761
993
|
},
|
|
762
994
|
}
|
|
@@ -1215,21 +1447,46 @@ async function sendChatImage(client, chatId, absPath) {
|
|
|
1215
1447
|
// msgId 供按钮回调/表情命中(takeNotice 锚点),cardId 供后续实体全量更新(单卡回填)。
|
|
1216
1448
|
// 投递一张已建好的卡:优先「回复」到指定消息(群里看得出在回应谁),没有消息 id 或回复被拒
|
|
1217
1449
|
// (原消息已撤回/超出可回复期)就退回直接发到群里。返回 message_id(撤回/表情命中锚点)。
|
|
1218
|
-
async function postCard(client, chatId, replyToMsgId, cardId
|
|
1450
|
+
async function postCard(client, chatId, replyToMsgId, cardId,
|
|
1451
|
+
{ operationBoundary = null, operationKey = null } = {}) {
|
|
1219
1452
|
const content = JSON.stringify({ type: 'card', data: { card_id: cardId } })
|
|
1220
1453
|
const pick = (r) => (r && ((r.data && r.data.message_id) || r.message_id)) || null
|
|
1221
1454
|
if (replyToMsgId) {
|
|
1222
1455
|
try {
|
|
1223
1456
|
// ⚠ SDK 对业务错误不抛(HTTP 200 + code≠0)⇒ 判成功要看 code,不能只看有没有 id:
|
|
1224
1457
|
// 成功却没取到 id 时若落到下面的直发,群里就是**两张卡**(比失去撤回能力糟得多)。
|
|
1225
|
-
const r = await
|
|
1458
|
+
const r = await cardOperation(operationBoundary, operationKey, 'message.reply',
|
|
1459
|
+
{ chatId, cardId, replyToMsgId }, async () => {
|
|
1460
|
+
const response = await client.im.message.reply({ path: { message_id: replyToMsgId },
|
|
1461
|
+
data: { msg_type: 'interactive', content } })
|
|
1462
|
+
if (operationBoundary && typeof response?.code !== 'number') {
|
|
1463
|
+
throw new Error('卡片回复结果未确认:未返回业务码')
|
|
1464
|
+
}
|
|
1465
|
+
if (operationBoundary && response?.code === 0 && !pick(response)) {
|
|
1466
|
+
throw new Error('卡片回复结果未确认:未返回 message_id')
|
|
1467
|
+
}
|
|
1468
|
+
return response
|
|
1469
|
+
})
|
|
1226
1470
|
if (r && r.code === 0) return pick(r)
|
|
1227
|
-
} catch {
|
|
1471
|
+
} catch (e) {
|
|
1472
|
+
if (operationBoundary) throw e
|
|
1473
|
+
/* 原消息已撤回/超期 ⇒ 退回直接发 */
|
|
1474
|
+
}
|
|
1228
1475
|
}
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1476
|
+
const r = await cardOperation(operationBoundary, operationKey, 'message.create',
|
|
1477
|
+
{ chatId, cardId }, async () => {
|
|
1478
|
+
const response = await client.im.message.create({
|
|
1479
|
+
params: { receive_id_type: 'chat_id' },
|
|
1480
|
+
data: { receive_id: chatId, msg_type: 'interactive', content },
|
|
1481
|
+
})
|
|
1482
|
+
if (operationBoundary) {
|
|
1483
|
+
assertCardOk(response, '投递卡')
|
|
1484
|
+
if (response?.code !== 0) throw new Error('卡片投递结果未确认:未返回 code=0')
|
|
1485
|
+
if (!pick(response)) throw new Error('卡片投递结果未确认:未返回 message_id')
|
|
1486
|
+
}
|
|
1487
|
+
return response
|
|
1488
|
+
})
|
|
1489
|
+
return pick(r)
|
|
1233
1490
|
}
|
|
1234
1491
|
|
|
1235
1492
|
async function sendChatCard(client, chatId, cardJson) {
|
|
@@ -1255,7 +1512,7 @@ async function sendChatCard(client, chatId, cardJson) {
|
|
|
1255
1512
|
// sequence 缺省 = 秒级时间戳(单发一张卡时用它最省事);写入器那种「同一张卡要写很多次」的
|
|
1256
1513
|
// 场合必须自带序号,由它那个唯一的分配器统一发 —— 两条路各自取号才是会撞的写法。
|
|
1257
1514
|
async function updateCardEntity(client, cardId, cardJson, sequence = null) {
|
|
1258
|
-
assertCardOk(await client.cardkit.v1.card.update({
|
|
1515
|
+
return assertCardOk(await client.cardkit.v1.card.update({
|
|
1259
1516
|
path: { card_id: cardId },
|
|
1260
1517
|
data: { card: { type: 'card_json', data: JSON.stringify(cardJson) }, sequence: sequence == null ? Math.floor(Date.now() / 1000) : sequence },
|
|
1261
1518
|
}), '更新卡')
|
|
@@ -1287,7 +1544,7 @@ async function fetchChatHistory(client, chatId, n = 20) {
|
|
|
1287
1544
|
createTimeMs: Number(m.create_time) || 0,
|
|
1288
1545
|
msgType: m.msg_type,
|
|
1289
1546
|
text: parseText({ message_type: m.msg_type, message_id: m.message_id, content: (m.body && m.body.content) || '' }), // message_id 进附件标记 ⇒ 历史里的图也能 download
|
|
1290
|
-
// ⚠ @
|
|
1547
|
+
// ⚠ @ 信息只在**这一层**补:parseText 把 `@_user_N` 占位符抹掉是对的(实时收信那条路也用它,
|
|
1291
1548
|
// agent 每轮收到的正文本就不该带占位符),但历史里因此看不出「这条是发给崔总的」还是「问我的」——
|
|
1292
1549
|
// 排查者据此误判过「真人消息被静默吞掉」(实为 @了别人未@bot,门控正确不触发);agent 读 history 也分不清。
|
|
1293
1550
|
// 某项目近 200 条里真人 76 条,12 条(16%)是这种。id 可能是字符串也可能是 {open_id} 对象,两种都认。
|