sintbot 1.4.7 → 1.4.9
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 +42 -0
- package/CHANGELOG.json +42 -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/agent/privatecore.js +1 -1
- package/src/agent/privatecore.wasm +0 -0
- 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/c2cardterminalpath.js +63 -7
- package/src/core/c2durableruntime.js +48 -4
- package/src/core/c2localeffectexecutor.js +3 -4
- package/src/core/c2sourceidentity.js +167 -0
- package/src/core/c2terminaleffectbridge.js +12 -1
- package/src/core/v2engine.js +159 -23
- package/src/feishucli.js +211 -0
- package/src/platform/feishu.js +1 -1
- 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
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// receive only the v2 decision and stable effect/receipt identities.
|
|
7
7
|
const { C2DurableRuntime } = require('./c2durableruntime')
|
|
8
8
|
const { C2PayloadHandles } = require('./c2payloadhandles')
|
|
9
|
+
const { engineTerminalStep } = require('../agent/privatecore')
|
|
10
|
+
|
|
11
|
+
const FAILURE_NOTICE = '本次任务执行失败。若已执行过工具,请先核对实际结果,再决定是否重试。'
|
|
9
12
|
|
|
10
13
|
const RECEIPT_ID = /^[0-9a-f]{32}$/
|
|
11
14
|
const GENERATION = /^[0-9a-f]{32}$/
|
|
@@ -23,6 +26,14 @@ function terminalReplyText(payload) {
|
|
|
23
26
|
typeof payload.answerText !== 'string') {
|
|
24
27
|
throw new TypeError('C2 terminal reply text fields invalid')
|
|
25
28
|
}
|
|
29
|
+
// The core classifies the result; the host supplies a fixed display string.
|
|
30
|
+
// Neither a provider error nor unfinished assistant text is a final answer.
|
|
31
|
+
if (payload.source === 'result' && payload.isError === true) {
|
|
32
|
+
const event = { ...payload, type: 'finalize' }
|
|
33
|
+
delete event.receiptId; delete event.turnToken; delete event.quiet
|
|
34
|
+
if (engineTerminalStep({ finalized: false, outcome: null }, event)
|
|
35
|
+
.state.outcome.status === 'error') return FAILURE_NOTICE
|
|
36
|
+
}
|
|
26
37
|
return (payload.answerText.trim() ? payload.answerText : payload.bufferText) ||
|
|
27
38
|
payload.resultText
|
|
28
39
|
}
|
|
@@ -106,7 +117,7 @@ function prepareTerminalEffectBridge({ runtime, payloadHandles, receiptId, chatI
|
|
|
106
117
|
}
|
|
107
118
|
}
|
|
108
119
|
const effect = { effectId: final.effectId, receiptId, chatId,
|
|
109
|
-
turnToken, generation, verifyGeneration }
|
|
120
|
+
turnToken, generation, verifyGeneration, assertWriterLock }
|
|
110
121
|
if (final.type === 'quietCompletion') {
|
|
111
122
|
if (replyText !== undefined || resolveTarget !== undefined ||
|
|
112
123
|
sendReply !== undefined || confirmReply !== undefined) {
|
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')
|
|
@@ -37,6 +39,31 @@ function humanDuration(ms) {
|
|
|
37
39
|
if (minutes < 60) return `${minutes} 分 ${seconds % 60} 秒`
|
|
38
40
|
return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`
|
|
39
41
|
}
|
|
42
|
+
// The final card is an accounting line: keep seconds for short turns and
|
|
43
|
+
// switch to minutes/hours before a long turn becomes an unreadable number.
|
|
44
|
+
function cardDuration(ms) {
|
|
45
|
+
const seconds = Math.max(0, Math.round(ms / 1000))
|
|
46
|
+
if (seconds < 60) return `${seconds} 秒`
|
|
47
|
+
const minutes = Math.floor(seconds / 60)
|
|
48
|
+
if (minutes < 60) return seconds % 60 ? `${minutes} 分 ${seconds % 60} 秒` : `${minutes} 分钟`
|
|
49
|
+
const hours = Math.floor(minutes / 60)
|
|
50
|
+
return minutes % 60 ? `${hours} 小时 ${minutes % 60} 分` : `${hours} 小时`
|
|
51
|
+
}
|
|
52
|
+
function cardModelInfo(group, provider) {
|
|
53
|
+
if (!provider) return { model: '', effort: '' }
|
|
54
|
+
const model = group?.model || provider.model ||
|
|
55
|
+
(provider.runner === 'codex' ? '' : provider.env?.ANTHROPIC_MODEL) || ''
|
|
56
|
+
const effort = provider.runner === 'codex'
|
|
57
|
+
? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL
|
|
58
|
+
const clean = value => String(value || '').replace(/\s+/g, ' ').trim()
|
|
59
|
+
return { model: clean(model), effort: clean(effort) }
|
|
60
|
+
}
|
|
61
|
+
function cardModelDetail(info, result) {
|
|
62
|
+
// A rejected configured effort may have been retried at the model default.
|
|
63
|
+
const fellBack = result?.effortFellBack || result?.result?.effortFellBack
|
|
64
|
+
return [info?.model, fellBack ? '' : info?.effort]
|
|
65
|
+
.filter(Boolean).join(' ')
|
|
66
|
+
}
|
|
40
67
|
// Group texts name the project by its folder only, never by a machine path.
|
|
41
68
|
function projectName(group) {
|
|
42
69
|
return String(group?.workdir || '').split(/[\\/]/).filter(Boolean).pop() || '(未设)'
|
|
@@ -102,9 +129,11 @@ const T = {
|
|
|
102
129
|
skipPending: '正在跳过当前任务,这条消息没有处理,请稍后重发。',
|
|
103
130
|
quotaFailure: 'Codex 订阅额度已用完,本次任务未完成。请恢复额度后重新发送;本次请求不会自动重试。',
|
|
104
131
|
cardFooter: ({ ms, tokens }) => {
|
|
105
|
-
const parts = [`耗时 ${
|
|
132
|
+
const parts = [`耗时 ${cardDuration(ms)}`]
|
|
106
133
|
const tokenLabel = n => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)
|
|
107
|
-
if (tokens
|
|
134
|
+
if (tokens && ((tokens.in || 0) + (tokens.cr || 0) + (tokens.cw || 0) + (tokens.out || 0)) > 0) {
|
|
135
|
+
parts.push(`tokens ↑${tokenLabel((tokens.in || 0) + (tokens.cr || 0) + (tokens.cw || 0))} ↓${tokenLabel(tokens.out || 0)}`)
|
|
136
|
+
}
|
|
108
137
|
return parts.join(' · ')
|
|
109
138
|
},
|
|
110
139
|
}
|
|
@@ -134,6 +163,20 @@ function supportedV2QuotaFailure(payload, runnerResult) {
|
|
|
134
163
|
outcome.sideEffects === false
|
|
135
164
|
}
|
|
136
165
|
|
|
166
|
+
// Admit only an observed, closed result on this release's no-retry route.
|
|
167
|
+
// Error classification remains in the core; close/session/effect authority is
|
|
168
|
+
// checked by the terminal path before any delivery can be attempted.
|
|
169
|
+
function supportedV2ClosedFailure(payload, runnerResult) {
|
|
170
|
+
if (!payload || payload.source !== 'result' || payload.aborted ||
|
|
171
|
+
payload.sessionMissing || payload.transientNetwork || payload.hasQuota ||
|
|
172
|
+
payload.freshRetried || payload.resumedSessionCurrent ||
|
|
173
|
+
runnerResult?.aborted !== false) return false
|
|
174
|
+
const terminal = { ...payload, type: 'finalize' }
|
|
175
|
+
delete terminal.receiptId; delete terminal.turnToken; delete terminal.quiet
|
|
176
|
+
return engineTerminalStep({ finalized: false, outcome: null }, terminal)
|
|
177
|
+
.state.outcome.status === 'error'
|
|
178
|
+
}
|
|
179
|
+
|
|
137
180
|
function ordinaryV2Cohort(snapshot, jobs, binding, chatId, receiptId) {
|
|
138
181
|
if (!Array.isArray(jobs) || !jobs.length ||
|
|
139
182
|
jobs.some(job => !job || job.msg?.batch || job.msg?.synthetic ||
|
|
@@ -171,6 +214,39 @@ function v2QueueSelection(queue) {
|
|
|
171
214
|
if (jobs.some(job => !job)) throw new Error('binary selected unknown queue handle')
|
|
172
215
|
return { jobs, anchor: effect.anchor.slice('receipt:job:'.length) }
|
|
173
216
|
}
|
|
217
|
+
|
|
218
|
+
// The group skills the release entry serves through `sbot feishu` (the same
|
|
219
|
+
// executable as `sintbot`): messages, notice cards, history, reply, edit,
|
|
220
|
+
// recall, download and reactions. Canvas, image generation and takeover cards
|
|
221
|
+
// are not in this release, and neither are scheduled tasks, so the cron
|
|
222
|
+
// section the core renders after the skills is cut off.
|
|
223
|
+
function releaseSkillLines(platform) {
|
|
224
|
+
const features = manifestFeatures({ ...(platform?.features || {}),
|
|
225
|
+
boards: false, imagen: false, takeoverCard: false })
|
|
226
|
+
const lines = manifestSkills({ features,
|
|
227
|
+
platformName: String(platform?.chatLabel || '飞书群').replace(/群$/, '') })
|
|
228
|
+
const cron = lines.findIndex(line => line.startsWith('【定时任务】'))
|
|
229
|
+
return cron < 0 ? lines : lines.slice(0, cron)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The group skills call `sbot feishu`. A machine can carry another `sbot` on
|
|
233
|
+
// PATH (an older global install), so a turn puts first the bin directory whose
|
|
234
|
+
// `sbot` is this very entry: an npm global prefix's bin next to
|
|
235
|
+
// lib/node_modules/sintbot. Nothing is changed when no such directory exists
|
|
236
|
+
// (a source checkout, tests).
|
|
237
|
+
let releaseBin
|
|
238
|
+
function releaseBinDir(entry = path.join(__dirname, '..', '..', 'bin', 'sintbot-v2.js')) {
|
|
239
|
+
if (releaseBin !== undefined && arguments.length === 0) return releaseBin
|
|
240
|
+
let found = null
|
|
241
|
+
try {
|
|
242
|
+
const self = fs.realpathSync(entry)
|
|
243
|
+
const dir = path.resolve(path.dirname(self), '..', '..', '..', '..', 'bin')
|
|
244
|
+
if (fs.realpathSync(path.join(dir, 'sbot')) === self) found = dir
|
|
245
|
+
} catch {}
|
|
246
|
+
if (arguments.length === 0) releaseBin = found
|
|
247
|
+
return found
|
|
248
|
+
}
|
|
249
|
+
|
|
174
250
|
class Engine {
|
|
175
251
|
constructor(cfg, options = {}) {
|
|
176
252
|
const { store, log = () => {}, durableV2Runtime, durableV2Payloads,
|
|
@@ -347,7 +423,9 @@ _rt(chatId) {
|
|
|
347
423
|
|
|
348
424
|
baseSkillEnv(chatId) {
|
|
349
425
|
const g = (this.cfg.groups || {})[chatId]
|
|
350
|
-
|
|
426
|
+
const bin = releaseBinDir()
|
|
427
|
+
return { ...(bin ? { PATH: [bin, process.env.PATH].filter(Boolean).join(path.delimiter) } : {}),
|
|
428
|
+
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
429
|
}
|
|
352
430
|
|
|
353
431
|
_resolveGroup(chatId) {
|
|
@@ -744,7 +822,9 @@ async _runDurableV2Steer(rt, receiptId) {
|
|
|
744
822
|
invoke: () => control.steerDurable({ msgId: job.msg.msgId, text }) })
|
|
745
823
|
entry.accepted = accepted
|
|
746
824
|
if (accepted) {
|
|
747
|
-
if (entry.landed)
|
|
825
|
+
if (entry.landed) {
|
|
826
|
+
authority.commitSteerObservation({ ...identity, type: 'landed' })
|
|
827
|
+
}
|
|
748
828
|
return { receiptId, steered: true, accepted: true, landed: entry.landed, staged: true }
|
|
749
829
|
}
|
|
750
830
|
if (entry.landed) throw new Error('C2 rejected steer also landed; outcome inconsistent')
|
|
@@ -766,9 +846,20 @@ async _runDurableV2Steer(rt, receiptId) {
|
|
|
766
846
|
return { receiptId, queued: true, staged: true, accepted: false }
|
|
767
847
|
})()
|
|
768
848
|
rt.v2QueueNoticePromise = work
|
|
769
|
-
|
|
849
|
+
let outcome
|
|
850
|
+
try { outcome = await work }
|
|
770
851
|
catch (error) { authority.close(); rt.v2Controller?.abort(); this.durableV2SealCapacity?.(error); throw error }
|
|
771
852
|
finally { if (rt.v2QueueNoticePromise === work) rt.v2QueueNoticePromise = null }
|
|
853
|
+
try {
|
|
854
|
+
if (outcome.accepted && entry.landed) {
|
|
855
|
+
await rt.v2SteerLandedChain
|
|
856
|
+
await rt.v2MoveCard?.(entry)
|
|
857
|
+
}
|
|
858
|
+
return outcome
|
|
859
|
+
} catch (error) {
|
|
860
|
+
authority.close(); rt.v2Controller?.abort(); this.durableV2SealCapacity?.(error)
|
|
861
|
+
throw error
|
|
862
|
+
}
|
|
772
863
|
}
|
|
773
864
|
|
|
774
865
|
async runDurableV2Stop(msg, platform) {
|
|
@@ -1166,7 +1257,8 @@ async _drainDurableV2NormalChain(initial) {
|
|
|
1166
1257
|
const outcome = engineTerminalStep({ finalized: false, outcome: null },
|
|
1167
1258
|
terminal).state.outcome
|
|
1168
1259
|
const quotaFailure = supportedV2QuotaFailure(payload, rt.v2RunnerResult)
|
|
1169
|
-
|
|
1260
|
+
const closedFailure = supportedV2ClosedFailure(payload, rt.v2RunnerResult)
|
|
1261
|
+
if (['error', 'empty', 'stopped'].includes(outcome.status) && !quotaFailure && !closedFailure) {
|
|
1170
1262
|
const classified = this.classifyDurableV2UnresolvedTurn({ ...identity,
|
|
1171
1263
|
payload })
|
|
1172
1264
|
const error = new Error('C2 v2 ordinary result requires fault recovery')
|
|
@@ -1177,7 +1269,7 @@ async _drainDurableV2NormalChain(initial) {
|
|
|
1177
1269
|
if (payload.source !== 'result' || payload.sessionMissing ||
|
|
1178
1270
|
(payload.hasQuota && !quotaFailure) || payload.transientNetwork || payload.freshRetried ||
|
|
1179
1271
|
payload.resumedSessionCurrent ||
|
|
1180
|
-
(!['ok', 'no-reply'].includes(outcome.status) && !quotaFailure)) {
|
|
1272
|
+
(!['ok', 'no-reply'].includes(outcome.status) && !quotaFailure && !closedFailure)) {
|
|
1181
1273
|
authority.close()
|
|
1182
1274
|
const error = new Error('C2 v2 ordinary result needs a retry or notice decision')
|
|
1183
1275
|
error.code = 'C2_V2_UNRESOLVED_TURN'
|
|
@@ -1195,7 +1287,7 @@ async _drainDurableV2NormalChain(initial) {
|
|
|
1195
1287
|
for (const settledReceiptId of completed.settledReceiptIds ||
|
|
1196
1288
|
[identity.receiptId]) {
|
|
1197
1289
|
settled.push({ receiptId: settledReceiptId,
|
|
1198
|
-
status: quotaFailure ? 'error' : silent ? 'silent' : 'answered',
|
|
1290
|
+
status: quotaFailure || closedFailure ? 'error' : silent ? 'silent' : 'answered',
|
|
1199
1291
|
terminalEffectId: completed.terminalEffectId })
|
|
1200
1292
|
}
|
|
1201
1293
|
if (authority.blocked) {
|
|
@@ -1399,7 +1491,12 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1399
1491
|
rt.v2Doing = []
|
|
1400
1492
|
rt.v2AutoMilestones = []
|
|
1401
1493
|
rt.v2CardQuotaFailure = false
|
|
1494
|
+
rt.v2CardModelInfo = null
|
|
1402
1495
|
rt.v2CardBinding = null
|
|
1496
|
+
rt.v2CardAnchor = null
|
|
1497
|
+
rt.v2CardJoinedNames = []
|
|
1498
|
+
rt.v2LastCardText = ''
|
|
1499
|
+
rt.v2MoveCard = null
|
|
1403
1500
|
rt.v2StreamRequested = false
|
|
1404
1501
|
rt.v2StreamQuiescing = false
|
|
1405
1502
|
rt.v2StreamChain = Promise.resolve()
|
|
@@ -1435,6 +1532,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1435
1532
|
if (!provider || (provider.baseUrl && !provider.apiKey)) {
|
|
1436
1533
|
throw new Error('C2 v2 runner provider unavailable')
|
|
1437
1534
|
}
|
|
1535
|
+
rt.v2CardModelInfo = cardModelInfo(job.group, provider)
|
|
1438
1536
|
rt.v2SteerControl = null
|
|
1439
1537
|
rt.v2SteerEntries = new Map()
|
|
1440
1538
|
rt.v2SteerClosing = false
|
|
@@ -1479,6 +1577,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1479
1577
|
if (rt.v2CardBinding?.opened) await rt.v2CardBinding.path.flushForTerminal()
|
|
1480
1578
|
if (rt.v2RunnerGeneration !== generation) return
|
|
1481
1579
|
authority.commitSteerObservation({ ...steerIdentity, receiptId: entry.identity.receiptId, type: 'landed' })
|
|
1580
|
+
await rt.v2MoveCard?.(entry)
|
|
1482
1581
|
})
|
|
1483
1582
|
rt.v2SteerLandedChain = observation
|
|
1484
1583
|
observation.catch(() => { authority.close(); controller?.abort() })
|
|
@@ -1494,6 +1593,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1494
1593
|
rt.v2RunnerNotStarted = false
|
|
1495
1594
|
let streamChain = Promise.resolve()
|
|
1496
1595
|
let streamFailure = null
|
|
1596
|
+
const joinedLine = () => rt.v2CardJoinedNames.length
|
|
1597
|
+
? `\n\n↪ 已加入 ${rt.v2CardJoinedNames.join('、')} 的补充` : ''
|
|
1497
1598
|
// Card progress (same mechanism and wording as the legacy engine): deterministic
|
|
1498
1599
|
// milestones and "recently doing" from tool calls, an early "received" card
|
|
1499
1600
|
// and a pulse line. All of it is process-local card text; the card itself is
|
|
@@ -1510,6 +1611,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1510
1611
|
rt.v2TerminalStarted || controller.signal.aborted || rt.v2CancelControl ||
|
|
1511
1612
|
rt.v2StreamQuiescing || !!authority.scopeState('stopIntent', chatId)
|
|
1512
1613
|
const enqueueCardText = text => {
|
|
1614
|
+
rt.v2LastCardText = text
|
|
1513
1615
|
rt.v2StreamRequested = true
|
|
1514
1616
|
streamChain = streamChain.then(async () => {
|
|
1515
1617
|
if (rt.v2QueueNoticePromise) await rt.v2QueueNoticePromise
|
|
@@ -1521,7 +1623,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1521
1623
|
if (!rt.v2CardBinding) {
|
|
1522
1624
|
const path = prepareCardTerminalPath({ runtime: authority,
|
|
1523
1625
|
payloadHandles: this.durableV2Payloads, platform: job.platform,
|
|
1524
|
-
replyCtx: job.msg.replyCtx,
|
|
1626
|
+
replyCtx: job.msg.replyCtx, cardAnchor: rt.v2CardAnchor,
|
|
1627
|
+
receiptId, chatId, turnToken,
|
|
1525
1628
|
generation, verifyGeneration,
|
|
1526
1629
|
applyLocalEffect: this.durableV2ApplyLocalEffect,
|
|
1527
1630
|
assertWriterLock: this.durableV2AssertWriterLock,
|
|
@@ -1536,7 +1639,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1536
1639
|
rt.v2CardBinding = { path, receiptId, turnToken,
|
|
1537
1640
|
generation, opened }
|
|
1538
1641
|
}
|
|
1539
|
-
if (rt.v2CardBinding.opened) rt.v2CardBinding.path.update(text)
|
|
1642
|
+
if (rt.v2CardBinding.opened) rt.v2CardBinding.path.update(text + joinedLine())
|
|
1540
1643
|
})
|
|
1541
1644
|
rt.v2StreamChain = streamChain
|
|
1542
1645
|
streamChain.catch(error => {
|
|
@@ -1544,6 +1647,33 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1544
1647
|
try { controller.abort() } catch {}
|
|
1545
1648
|
})
|
|
1546
1649
|
}
|
|
1650
|
+
rt.v2MoveCard = entry => {
|
|
1651
|
+
if (entry.movePromise) return entry.movePromise
|
|
1652
|
+
const move = streamChain.then(async () => {
|
|
1653
|
+
if (progressClosed() || job.platform.features?.cardPinned !== true) return false
|
|
1654
|
+
const anchor = { receiptId: entry.identity.receiptId,
|
|
1655
|
+
replyCtx: entry.job.msg.replyCtx }
|
|
1656
|
+
const who = entry.job.msg.senderName || entry.job.msg.userId || 'user'
|
|
1657
|
+
if (!rt.v2CardJoinedNames.includes(who)) rt.v2CardJoinedNames.push(who)
|
|
1658
|
+
if (!rt.v2CardBinding) {
|
|
1659
|
+
rt.v2CardAnchor = anchor
|
|
1660
|
+
return true
|
|
1661
|
+
}
|
|
1662
|
+
if (!rt.v2CardBinding.opened) return false
|
|
1663
|
+
rt.v2CardBinding.path.update((rt.v2LastCardText || progress.T.ackWorking) + joinedLine())
|
|
1664
|
+
const moved = await rt.v2CardBinding.path.relocate(anchor)
|
|
1665
|
+
if (moved) rt.v2CardAnchor = anchor
|
|
1666
|
+
return moved
|
|
1667
|
+
})
|
|
1668
|
+
streamChain = move
|
|
1669
|
+
rt.v2StreamChain = move
|
|
1670
|
+
entry.movePromise = move
|
|
1671
|
+
move.catch(error => {
|
|
1672
|
+
streamFailure = error
|
|
1673
|
+
try { controller.abort() } catch {}
|
|
1674
|
+
})
|
|
1675
|
+
return move
|
|
1676
|
+
}
|
|
1547
1677
|
const enqueueFrame = event => {
|
|
1548
1678
|
if (event.type !== 'assistant') return
|
|
1549
1679
|
// /status and the pulse read these; a late event from an older
|
|
@@ -1613,10 +1743,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1613
1743
|
const sofar = buffer.trim()
|
|
1614
1744
|
const body = autoBlock() + (sofar && !NO_REPLY.startsWith(sofar)
|
|
1615
1745
|
? progress.renderMilestones(buffer) + '\n\n' : '')
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
progress.humanDur(Date.now() - pulseFrom), rt.v2Doing, stale))
|
|
1619
|
-
} catch { /* a closed card never affects the turn */ }
|
|
1746
|
+
enqueueCardText(body + progress.T.pulse(
|
|
1747
|
+
progress.humanDur(Date.now() - pulseFrom), rt.v2Doing, stale))
|
|
1620
1748
|
}, PULSE_MS)
|
|
1621
1749
|
if (pulseTimer.unref) pulseTimer.unref()
|
|
1622
1750
|
}
|
|
@@ -1835,6 +1963,7 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
1835
1963
|
const projected = rt?.v2TerminalProjection
|
|
1836
1964
|
const resultText = projected?.payload?.resultText
|
|
1837
1965
|
const quotaFailure = supportedV2QuotaFailure(payload, rt?.v2RunnerResult)
|
|
1966
|
+
const closedFailure = supportedV2ClosedFailure(payload, rt?.v2RunnerResult)
|
|
1838
1967
|
if (!/^[0-9a-f]{32}$/.test(receiptId || '') ||
|
|
1839
1968
|
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
1840
1969
|
typeof effectId !== 'string' || !effectId ||
|
|
@@ -1866,10 +1995,9 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
1866
1995
|
(!isDeepStrictEqual(payload.batchReceiptIds, cohort.receiptIds) ||
|
|
1867
1996
|
payload.replyTargetReceiptId !== receiptId)) ||
|
|
1868
1997
|
!isDeepStrictEqual(payload, projected.payload) ||
|
|
1869
|
-
//
|
|
1870
|
-
//
|
|
1871
|
-
|
|
1872
|
-
payload.source !== 'result' || payload.aborted || (payload.isError && !quotaFailure) ||
|
|
1998
|
+
// A deterministic failure is delivered once, after the same durable
|
|
1999
|
+
// close checks as success. Retry and unknown outcomes stay sealed.
|
|
2000
|
+
payload.source !== 'result' || payload.aborted || (payload.isError && !quotaFailure && !closedFailure) ||
|
|
1873
2001
|
payload.sessionMissing || (payload.hasQuota && !quotaFailure) || payload.transientNetwork ||
|
|
1874
2002
|
payload.freshRetried || payload.resumedSessionCurrent ||
|
|
1875
2003
|
(replyText !== undefined && replyText !== terminalReplyText(projected.payload)) ||
|
|
@@ -1883,6 +2011,9 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
1883
2011
|
value.generation === generation && rt.v2RunnerGeneration === generation
|
|
1884
2012
|
try {
|
|
1885
2013
|
const preopened = rt.v2CardBinding
|
|
2014
|
+
if (closedFailure) authority.assertClosedFailureReady({ receiptId, chatId,
|
|
2015
|
+
turnToken, generation, verifyGeneration,
|
|
2016
|
+
assertWriterLock: this.durableV2AssertWriterLock })
|
|
1886
2017
|
if (preopened && (preopened.receiptId !== receiptId ||
|
|
1887
2018
|
preopened.turnToken !== turnToken ||
|
|
1888
2019
|
preopened.generation !== generation)) {
|
|
@@ -1890,7 +2021,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
1890
2021
|
}
|
|
1891
2022
|
const path = preopened?.path || prepareCardTerminalPath({ runtime: authority,
|
|
1892
2023
|
payloadHandles: handles, platform: job.platform,
|
|
1893
|
-
replyCtx: job.msg.replyCtx,
|
|
2024
|
+
replyCtx: job.msg.replyCtx, cardAnchor: rt.v2CardAnchor, preferPlain: closedFailure,
|
|
2025
|
+
receiptId, chatId, turnToken, generation,
|
|
1894
2026
|
verifyGeneration, applyLocalEffect: this.durableV2ApplyLocalEffect,
|
|
1895
2027
|
assertWriterLock: this.durableV2AssertWriterLock,
|
|
1896
2028
|
redactText: text => redact(quotaFailure
|
|
@@ -1905,6 +2037,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
1905
2037
|
for (const text of updates) path.update(text)
|
|
1906
2038
|
const footer = T.cardFooter({ ms: Math.max(0, Date.now() - rt.v2StartedAt),
|
|
1907
2039
|
tokens: rt.v2UsageDecision?.tokens || null })
|
|
2040
|
+
const footerDetail = job.platform.features?.cardQuota === true
|
|
2041
|
+
? cardModelDetail(rt.v2CardModelInfo, rt.v2RunnerResult) : ''
|
|
1908
2042
|
// Milestones from tool calls first, then the model's own `>> ` lines;
|
|
1909
2043
|
// the card shows them in its "process" fold (display only).
|
|
1910
2044
|
rt.v2CardQuotaFailure = !!quotaFailure
|
|
@@ -1913,7 +2047,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
1913
2047
|
...progress.milestonesOf(payload.bufferText)]
|
|
1914
2048
|
.filter((m, i, a) => a.indexOf(m) === i).map(redactMilestone)
|
|
1915
2049
|
.filter(m => typeof m === 'string' && m.trim())
|
|
1916
|
-
const completed = await path.complete({ payload, replyText, paused,
|
|
2050
|
+
const completed = await path.complete({ payload, replyText, paused,
|
|
2051
|
+
footer, footerDetail, milestones })
|
|
1917
2052
|
const after = authority.snapshot()
|
|
1918
2053
|
const queue = after.state?.lanes?.queue?.[chatId]?.queue
|
|
1919
2054
|
let successor = null
|
|
@@ -2027,7 +2162,7 @@ classifyDurableV2UnresolvedTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
2027
2162
|
}
|
|
2028
2163
|
rt.v2TerminalStarted = true
|
|
2029
2164
|
authority.registerTerminalCallback({ receiptId, generation,
|
|
2030
|
-
registeredAt: Date.now() })
|
|
2165
|
+
registeredAt: Date.now(), reuse: true })
|
|
2031
2166
|
return authority.commitUnresolvedClassification({ receiptId, generation,
|
|
2032
2167
|
turnToken, status, payload,
|
|
2033
2168
|
verifyGeneration: value => value.chatId === chatId &&
|
|
@@ -2057,6 +2192,7 @@ classifyDurableV2UnresolvedTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
2057
2192
|
{ full: policy.full, isCodex: provider.runner === 'codex' }) : null,
|
|
2058
2193
|
unredactedPii: group.redactPii === false,
|
|
2059
2194
|
cardPinned: platform.features?.cardPinned === true, plainSend: false }),
|
|
2195
|
+
...releaseSkillLines(platform),
|
|
2060
2196
|
].join('\n')
|
|
2061
2197
|
// Reinject current supported context each turn. No legacy feature menu,
|
|
2062
2198
|
// external-message commands or v1 manifest refresh scheduler is included.
|
|
@@ -2146,4 +2282,4 @@ requestDurableV2Stop(chatId) {
|
|
|
2146
2282
|
|
|
2147
2283
|
}
|
|
2148
2284
|
|
|
2149
|
-
module.exports = { Engine }
|
|
2285
|
+
module.exports = { releaseBinDir, Engine }
|
package/src/feishucli.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Messaging actions of the `feishu` subcommand, shared by the legacy entry and
|
|
4
|
+
// the release entry so a group's agent gets the same skills under both:
|
|
5
|
+
// send-msg · send-notice · send-file · send-image · history · reply · edit ·
|
|
6
|
+
// recall (alias delete) · download · react. Canvas actions stay in the legacy
|
|
7
|
+
// entry. None of them needs the service: each call uses the bot's own app
|
|
8
|
+
// credentials directly.
|
|
9
|
+
//
|
|
10
|
+
// Target chat: --chat, else $SBOT_CHAT_ID (injected for every group turn).
|
|
11
|
+
// Configuration: --config, else $SBOT_CONFIG (also injected), else the usual
|
|
12
|
+
// lookup. Paths are relative to the working directory and never leave it;
|
|
13
|
+
// outbound text is redacted like the engine's replies (PII per group).
|
|
14
|
+
const fs = require('node:fs')
|
|
15
|
+
const config = require('./config')
|
|
16
|
+
const feishu = require('./platform/feishu')
|
|
17
|
+
const { cliNotice, deliverNotice } = require('./platform/noticesend')
|
|
18
|
+
const { TONES, resolveTone } = require('./platform/noticecard')
|
|
19
|
+
const { redact } = require('./core/redact')
|
|
20
|
+
const { fmtStamp, normalizeTz } = require('./core/time')
|
|
21
|
+
|
|
22
|
+
const FEISHU_MESSAGE_ACTIONS = Object.freeze(['send-msg', 'send-notice', 'send-file', 'send-image',
|
|
23
|
+
'history', 'reply', 'edit', 'recall', 'delete', 'download', 'react'])
|
|
24
|
+
|
|
25
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
26
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`
|
|
27
|
+
|
|
28
|
+
// A refusal with its own wording: callers print it as is and exit 1.
|
|
29
|
+
class FeishuCliError extends Error {
|
|
30
|
+
constructor(message) { super(message); this.name = 'FeishuCliError' }
|
|
31
|
+
}
|
|
32
|
+
const refuse = (message) => { throw new FeishuCliError(message) }
|
|
33
|
+
|
|
34
|
+
// Hand-roll option parsing (not util.parseArgs): send-msg bodies / filenames legitimately start
|
|
35
|
+
// with '-' (Markdown lists like "- item"), which parseArgs(strict) rejects as an unknown option —
|
|
36
|
+
// and the self-knowledge manifest teaches agents exactly that form. Pull out --chat/--config; the
|
|
37
|
+
// rest stays positional (message text / path), leading '-' and all. Canvas options are taken out
|
|
38
|
+
// for every action, as they always were.
|
|
39
|
+
function parseFeishuArgs(action, rawArgs) {
|
|
40
|
+
const o = { warnings: [], positionals: [] }
|
|
41
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
42
|
+
const a = rawArgs[i]
|
|
43
|
+
if (a === '--chat') o.chat = rawArgs[++i]
|
|
44
|
+
else if (a.startsWith('--chat=')) o.chat = a.slice(7)
|
|
45
|
+
else if (a === '--config') o.config = rawArgs[++i]
|
|
46
|
+
else if (a.startsWith('--config=')) o.config = a.slice(9)
|
|
47
|
+
else if (action === 'send-notice' && a === '--sub') o.subtitle = rawArgs[++i]
|
|
48
|
+
else if (action === 'send-notice' && a === '--tone') o.tone = rawArgs[++i]
|
|
49
|
+
else if (action === 'send-notice' && a === '--warn') o.warnings.push(rawArgs[++i])
|
|
50
|
+
else if (action === 'send-notice' && a === '--from') o.from = rawArgs[++i]
|
|
51
|
+
else if (a === '--title') o.title = rawArgs[++i]
|
|
52
|
+
else if (a === '--new') o.new = true
|
|
53
|
+
else if (a === '--x') o.x = rawArgs[++i]
|
|
54
|
+
else if (a === '--y') o.y = rawArgs[++i]
|
|
55
|
+
else if (a === '--width') o.width = rawArgs[++i]
|
|
56
|
+
else if (a === '--height') o.height = rawArgs[++i]
|
|
57
|
+
else if (a === '--label') o.label = rawArgs[++i]
|
|
58
|
+
else if (a === '--raw') o.raw = true
|
|
59
|
+
else if (a === '--board') o.board = rawArgs[++i]
|
|
60
|
+
else if (a === '--beside') o.beside = rawArgs[++i] // 参照节点 id:新图放它右侧(按标注改的排版)
|
|
61
|
+
else if (a === '--deliver') o.deliver = true // 交付结果卡:上板 + 回填进原播报卡(单卡)/ 无原卡则新发
|
|
62
|
+
else if (a === '--note') o.note = rawArgs[++i] // 结果卡里的一句改动说明
|
|
63
|
+
else if (a === '--type') o.type = rawArgs[++i] // 图表类型(flow/mindmap/sequence/… 或中文别名)
|
|
64
|
+
else if (a === '--syntax') o.syntax = rawArgs[++i] // 图表语法(mermaid | plantuml;覆盖扩展名识别)
|
|
65
|
+
else if (a === '--as-image') o.asImage = true // 图表渲染成整张图片,否则多节点可编辑
|
|
66
|
+
else if (a === '--no-cascade') o.noCascade = true // board-del-node:只删点名的,不连带容器内容/导图子树
|
|
67
|
+
else o.positionals.push(a)
|
|
68
|
+
}
|
|
69
|
+
return o
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The checks every action shares, in the legacy order: Slack groups, then
|
|
73
|
+
// configuration, then the target chat.
|
|
74
|
+
function feishuContext(opts, { env = process.env, loadConfig = config.loadConfig,
|
|
75
|
+
findConfigPath = config.findConfigPath, applyProxyEnv = config.applyProxyEnv,
|
|
76
|
+
feishuClient = feishu.feishuClient, client } = {}) {
|
|
77
|
+
// Slack 群:引擎注入 SBOT_PLATFORM=slack。飞书这族动作在那儿一概走不通(文件上下行还没接到
|
|
78
|
+
// Slack,docs/SLACK.md 第 3 步)。不拦的话要么打到飞书 API 报 lark 错、要么 feishu 没配时报「未配置」——
|
|
79
|
+
// 两种都不说人话,agent 会转头重试。显式 --chat 指向飞书群的手工用法不拦。
|
|
80
|
+
if (!opts.chat && env.SBOT_PLATFORM === 'slack') refuse('本群在 Slack 上 —— sbot feishu 的动作(发文件/发图/读历史/回复/编辑/撤回/下载/表情)在 Slack 群暂不可用,Slack 侧文件上下行还没接。要给群成员看的文件或图片:把项目内相对路径与打开方式写进回复正文;不要重试本命令。')
|
|
81
|
+
const configPath = opts.config || env.SBOT_CONFIG
|
|
82
|
+
if (!findConfigPath(configPath)) refuse('feishu 未配置:找不到配置文件 — 传 --config <path> 或设置 env SBOT_CONFIG')
|
|
83
|
+
const cfg = loadConfig(configPath)
|
|
84
|
+
if (!cfg.feishu || !cfg.feishu.appId || !cfg.feishu.appSecret) refuse('feishu 未配置(sbot.config.json 缺 feishu.appId/appSecret)')
|
|
85
|
+
const chatId = opts.chat || env.SBOT_CHAT_ID
|
|
86
|
+
if (!chatId) refuse('缺少目标群 — 传 --chat <oc_...> 或设置 env SBOT_CHAT_ID')
|
|
87
|
+
applyProxyEnv(cfg)
|
|
88
|
+
// 群面出站脱敏(与 engine 同口径):PII 按群开关(redactPii:false ⇒ 手机号/邮箱明文),密钥类恒遮盖。
|
|
89
|
+
const red = (s) => redact(s, { pii: !(cfg.groups && cfg.groups[chatId] && cfg.groups[chatId].redactPii === false) })
|
|
90
|
+
return { cfg, chatId, red, client: client || feishuClient(cfg.feishu.appId, cfg.feishu.appSecret) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// args = [action, ...rest]. Confirmations go to stderr; what a program reads
|
|
94
|
+
// (history lines, message ids) goes to stdout.
|
|
95
|
+
async function runFeishuAction(args, deps = {}) {
|
|
96
|
+
const action = args[0]
|
|
97
|
+
if (!FEISHU_MESSAGE_ACTIONS.includes(action)) refuse(`unknown feishu action '${action || ''}' (${FEISHU_MESSAGE_ACTIONS.filter((a) => a !== 'delete').join(' | ')})`)
|
|
98
|
+
const opts = parseFeishuArgs(action, args.slice(1))
|
|
99
|
+
const { cfg, chatId, red, client } = feishuContext(opts, deps)
|
|
100
|
+
const cwd = deps.cwd || process.cwd()
|
|
101
|
+
const now = deps.now || Date.now
|
|
102
|
+
const out = deps.stdout || ((s) => process.stdout.write(s))
|
|
103
|
+
const err = deps.stderr || ((s) => process.stderr.write(s))
|
|
104
|
+
const p = opts.positionals
|
|
105
|
+
// Usage errors before any network call, worded as the legacy entry did.
|
|
106
|
+
let notice
|
|
107
|
+
if (action === 'send-msg' && !p.join(' ').trim()) refuse('缺少消息内容 — sbot feishu send-msg "<文本>"')
|
|
108
|
+
if (action === 'send-notice') {
|
|
109
|
+
// 群通知卡:标题 + 正文(其余位置参数各占一行)+ 可选副标题 / 类别 / 注意事项 / 来源。
|
|
110
|
+
// 与 send-msg 的分工:这张卡是「对大家宣布一件事」,阶段性结果与定时提醒仍走 send-msg。
|
|
111
|
+
if (!String(p[0] || '').trim()) refuse('缺少标题 — sbot feishu send-notice "<标题>" ["<正文…>"] [--sub <副标题>] [--tone 公告|升级|提醒|故障|变更|状态] [--warn <注意事项>]… [--from <来源>]')
|
|
112
|
+
try {
|
|
113
|
+
notice = cliNotice(p, { subtitle: opts.subtitle, tone: opts.tone, warnings: opts.warnings,
|
|
114
|
+
from: opts.from, stamp: fmtStamp(now(), normalizeTz(cfg.timezone)) })
|
|
115
|
+
} catch (e) { refuse(e.message) }
|
|
116
|
+
}
|
|
117
|
+
if ((action === 'reply' || action === 'edit') && (!p[0] || !p.slice(1).join(' ').trim())) {
|
|
118
|
+
refuse(action === 'reply' ? '用法: sbot feishu reply <message_id> "<文本>"' : '用法: sbot feishu edit <message_id> "<文本>"(仅能编辑 bot 自己发的消息)')
|
|
119
|
+
}
|
|
120
|
+
if ((action === 'recall' || action === 'delete') && !p[0]) refuse('用法: sbot feishu recall <message_id>(仅能撤回 bot 自己发的消息)')
|
|
121
|
+
if (action === 'download' && (!p[0] || !p[1])) refuse('用法: sbot feishu download <message_id> <file_key> <file|image> [目标相对路径]')
|
|
122
|
+
if (action === 'react' && (!p[0] || !p[1])) refuse('用法: sbot feishu react <message_id> <emoji>(如 THUMBSUP / 👍)')
|
|
123
|
+
try {
|
|
124
|
+
if (action === 'send-file' || action === 'send-image') {
|
|
125
|
+
const abs = feishu.resolveSendPath(cwd, p[0])
|
|
126
|
+
if (action === 'send-file') await feishu.sendChatFile(client, chatId, abs)
|
|
127
|
+
else await feishu.sendChatImage(client, chatId, abs)
|
|
128
|
+
err(green(` ✓ 已发送${action === 'send-file' ? '文件' : '图片'}到群 `) + dim(chatId) + ' ' + p[0] + '\n')
|
|
129
|
+
return { action, chatId, path: abs }
|
|
130
|
+
}
|
|
131
|
+
if (action === 'send-msg') {
|
|
132
|
+
await feishu.sendChatText(client, chatId, red(p.join(' '))) // 出站脱敏,与 engine 群回复同口径(PII 按群)
|
|
133
|
+
err(green(' ✓ 已发送消息到群 ') + dim(chatId) + '\n')
|
|
134
|
+
return { action, chatId }
|
|
135
|
+
}
|
|
136
|
+
if (action === 'send-notice') {
|
|
137
|
+
// 有卡能力就发卡(逐字段脱敏);建卡被拒或没送达时退回同一份通知的文本。
|
|
138
|
+
const sent = await deliverNotice({ features: { noticeCard: true },
|
|
139
|
+
sendCard: (id, card) => feishu.sendChatCard(client, id, card),
|
|
140
|
+
send: (id, body) => feishu.sendChatText(client, id, body) }, chatId, notice, { redact: red, log: (m) => err(dim(' (' + m + ')') + '\n') })
|
|
141
|
+
if (sent.mode === 'card') {
|
|
142
|
+
err(green(' ✓ 已发送通知卡到群 ') + dim(chatId) + ` ${TONES[resolveTone(notice.tone)].label}「${notice.title}」\n`)
|
|
143
|
+
if (sent.result && sent.result.msgId) out(sent.result.msgId + '\n') // 给程序读:message_id(要撤回 / 回复它时用)
|
|
144
|
+
} else {
|
|
145
|
+
err(green(' ✓ 已发送通知到群 ') + dim(chatId) + '(文本)\n')
|
|
146
|
+
}
|
|
147
|
+
return { action, chatId, mode: sent.mode, msgId: (sent.result && sent.result.msgId) || null }
|
|
148
|
+
}
|
|
149
|
+
if (action === 'history') {
|
|
150
|
+
const n = p[0] ? parseInt(p[0], 10) : 20
|
|
151
|
+
const msgs = await feishu.fetchChatHistory(client, chatId, n)
|
|
152
|
+
const tz = normalizeTz(cfg.timezone)
|
|
153
|
+
// @ 标记(裁决在 feishu.js 的 historyAtMark,这里只消费):「@了别人未@bot」的消息按设计不触发轮次,
|
|
154
|
+
// 历史里不标就与真问 bot 的长得一模一样。bot 自己的 open_id 从配置来。
|
|
155
|
+
const botOid = String((cfg.feishu && cfg.feishu.botOpenId) || '')
|
|
156
|
+
const lines = msgs.map((m) => {
|
|
157
|
+
const who = m.senderType === 'app' ? 'bot' : (m.senderId || '?')
|
|
158
|
+
const body = m.text != null ? m.text : `(${m.msgType})`
|
|
159
|
+
return `[${m.createTimeMs ? fmtStamp(m.createTimeMs, tz) : '?'} ${who}${feishu.historyAtMark(m.mentions, botOid)}] ${body} {${m.msgId}}`
|
|
160
|
+
})
|
|
161
|
+
// 群消息内容是入站上下文(给 agent 读),不脱敏;message_id 用于后续 reply/edit/recall
|
|
162
|
+
out(`共 ${msgs.length} 条群消息(新→旧):\n` + (lines.join('\n') || '(空)') + '\n')
|
|
163
|
+
return { action, chatId, count: msgs.length }
|
|
164
|
+
}
|
|
165
|
+
if (action === 'reply' || action === 'edit') {
|
|
166
|
+
const msgId = p[0]
|
|
167
|
+
await (action === 'reply' ? feishu.replyMessage : feishu.editMessage)(client, msgId, red(p.slice(1).join(' ')))
|
|
168
|
+
err(green(action === 'reply' ? ' ✓ 已回复消息 ' : ' ✓ 已编辑消息 ') + dim(msgId) + '\n')
|
|
169
|
+
return { action, chatId, msgId }
|
|
170
|
+
}
|
|
171
|
+
if (action === 'recall' || action === 'delete') {
|
|
172
|
+
await feishu.recallMessage(client, p[0])
|
|
173
|
+
err(green(' ✓ 已撤回消息 ') + dim(p[0]) + '\n')
|
|
174
|
+
return { action, chatId, msgId: p[0] }
|
|
175
|
+
}
|
|
176
|
+
if (action === 'download') {
|
|
177
|
+
const [msgId, fileKey, type] = p
|
|
178
|
+
const dest = p[3] || fileKey
|
|
179
|
+
const abs = feishu.resolveSendPath(cwd, dest, false) // 写模式:目标文件尚不存在
|
|
180
|
+
await feishu.downloadResource(client, msgId, fileKey, type === 'image' ? 'image' : 'file', abs)
|
|
181
|
+
err(green(' ✓ 已下载到 ') + dim(dest) + '\n')
|
|
182
|
+
return { action, chatId, path: abs }
|
|
183
|
+
}
|
|
184
|
+
// react
|
|
185
|
+
await feishu.reactMessage(client, p[0], p[1])
|
|
186
|
+
err(green(' ✓ 已回应 ') + dim(p[0]) + ' ' + p[1] + '\n')
|
|
187
|
+
return { action, chatId, msgId: p[0] }
|
|
188
|
+
} catch (e) {
|
|
189
|
+
throw new FeishuCliError('feishu 操作失败:' + feishu.larkErrMsg(e))
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// `--help` text of the release entry. The legacy entry keeps its own, which
|
|
194
|
+
// also lists the canvas actions.
|
|
195
|
+
function feishuUsage(cmd = 'sintbot') {
|
|
196
|
+
return `用法: ${cmd} feishu <动作> [参数…] [--chat <oc_…>] [--config <path>]\n`
|
|
197
|
+
+ ' 目标群:--chat,不传取 $SBOT_CHAT_ID(群里跑时已注入);配置:--config,不传取 $SBOT_CONFIG;路径一律项目内相对路径\n'
|
|
198
|
+
+ '\n 消息\n'
|
|
199
|
+
+ ' send-msg "<文本>" 发文本(正文以 - 开头也照收)\n'
|
|
200
|
+
+ ' send-notice "<标题>" "<正文>" 发群通知卡(可加 --sub / --tone / --warn / --from)\n'
|
|
201
|
+
+ ' send-file <路径> · send-image <路径> 发文件 / 图片\n'
|
|
202
|
+
+ ' history [n] 拉最近 n 条(默认 20,最多 50):每条带 message_id,并标 →@某某·未@bot / →@bot\n'
|
|
203
|
+
+ ' reply <message_id> "<文本>" 回复某条\n'
|
|
204
|
+
+ ' edit <message_id> "<文本>" 改自己发过的消息\n'
|
|
205
|
+
+ ' recall <message_id> 撤回自己发过的消息(别名 delete)\n'
|
|
206
|
+
+ ' download <message_id> <file_key> <file|image> [目标路径] 下载群里的附件 / 图片(目标默认取 file_key)\n'
|
|
207
|
+
+ ' react <message_id> <表情> 表情回应(THUMBSUP 之类的枚举名,或直接给 👍)\n'
|
|
208
|
+
+ '\n 确认走 stderr;给程序读的(history / message_id)走 stdout;失败退出码 1\n'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = { FEISHU_MESSAGE_ACTIONS, FeishuCliError, parseFeishuArgs, feishuContext, runFeishuAction, feishuUsage }
|
package/src/platform/feishu.js
CHANGED
|
@@ -605,7 +605,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
605
605
|
}
|
|
606
606
|
|
|
607
607
|
return {
|
|
608
|
-
features: { cardQuota: true, noticeCard: true }, //
|
|
608
|
+
features: { cardQuota: true, noticeCard: true, cardPinned: true }, // 回复卡钉在触发消息下;补话落地后可搬到新消息下
|
|
609
609
|
async start(onMessage, hooks = {}) {
|
|
610
610
|
if (!botOpenId && !onlyWhenMentioned) log('warning: feishu.botOpenId not set — cannot tell @bot from @others; all group messages will be handled')
|
|
611
611
|
if (hooks.onFatal !== undefined && typeof hooks.onFatal !== 'function') {
|