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
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
4
|
+
|
|
5
|
+
// This is a fail-closed disk projection for the prospective C2 v2 state.
|
|
6
|
+
// It is not a general JSON serializer. Adding a reducer field or lane needs
|
|
7
|
+
// an explicit privacy review here before that state can be committed.
|
|
8
|
+
const MAX_STATE_BYTES = 1024 * 1024
|
|
9
|
+
const MAX_SCOPES = 128
|
|
10
|
+
const MAX_QUEUE = 1024
|
|
11
|
+
const MAX_MESSAGES = 256
|
|
12
|
+
const MAX_LATE_REPORTS = 64
|
|
13
|
+
const SAFE_INTEGER = Number.MAX_SAFE_INTEGER
|
|
14
|
+
const HEX_RECEIPT = /^[0-9a-f]{32}$/
|
|
15
|
+
const HEX_DIGEST = /^[0-9a-f]{64}$/
|
|
16
|
+
const JOB_ID = /^receipt:job:([0-9a-f]{32})$/
|
|
17
|
+
const MEMBER_ID = /^receipt:member:([0-9a-f]{32})$/
|
|
18
|
+
const SOURCE_ID = /^[A-Za-z0-9_-]{1,128}$/
|
|
19
|
+
const MESSAGE_ID = /^(?:om_[A-Za-z0-9_-]{1,124}|[0-9a-f]{32})$/
|
|
20
|
+
const EVENT_ID = /^[A-Za-z0-9:_-]{1,256}$/
|
|
21
|
+
const TEXT_HANDLE = /^sbot-c2-text-v1:[0-9a-f]{32}$/
|
|
22
|
+
|
|
23
|
+
function plain(value, label) {
|
|
24
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
25
|
+
Object.getPrototypeOf(value) !== Object.prototype ||
|
|
26
|
+
Object.getOwnPropertySymbols(value).length ||
|
|
27
|
+
Object.values(Object.getOwnPropertyDescriptors(value)).some(d => !Object.hasOwn(d, 'value') || !d.enumerable)) {
|
|
28
|
+
throw new TypeError(`${label} must be a plain data object`)
|
|
29
|
+
}
|
|
30
|
+
return value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fields(value, names, label, { optional = [] } = {}) {
|
|
34
|
+
plain(value, label)
|
|
35
|
+
const allowed = new Set([...names, ...optional])
|
|
36
|
+
if (Object.keys(value).some(key => !allowed.has(key)) || names.some(key => !Object.hasOwn(value, key))) {
|
|
37
|
+
throw new TypeError(`${label} fields invalid`)
|
|
38
|
+
}
|
|
39
|
+
return value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function integer(value, label, { positive = false } = {}) {
|
|
43
|
+
if (!Number.isSafeInteger(value) || value < (positive ? 1 : 0)) throw new TypeError(`${label} invalid`)
|
|
44
|
+
return value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function bool(value, label) {
|
|
48
|
+
if (typeof value !== 'boolean') throw new TypeError(`${label} invalid`)
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function token(value, pattern, label) {
|
|
53
|
+
if (typeof value !== 'string' || !pattern.test(value)) throw new TypeError(`${label} invalid`)
|
|
54
|
+
return value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function nullable(value, validate) { return value === null ? null : validate(value) }
|
|
58
|
+
|
|
59
|
+
function boundedArray(value, max, label) {
|
|
60
|
+
if (!Array.isArray(value) || value.length > max ||
|
|
61
|
+
Reflect.ownKeys(value).length !== value.length + 1 ||
|
|
62
|
+
!Array.from({ length: value.length }, (_, index) =>
|
|
63
|
+
Object.getOwnPropertyDescriptor(value, index)).every(d => d && Object.hasOwn(d, 'value') && d.enumerable)) {
|
|
64
|
+
throw new TypeError(`${label} invalid`)
|
|
65
|
+
}
|
|
66
|
+
return value
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function unique(values, label) {
|
|
70
|
+
if (new Set(values).size !== values.length) throw new TypeError(`${label} has duplicate IDs`)
|
|
71
|
+
return values
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function queueLane(raw) {
|
|
75
|
+
fields(raw, ['queue'], 'queue lane')
|
|
76
|
+
const ids = []
|
|
77
|
+
const queue = boundedArray(raw.queue, MAX_QUEUE, 'queue').map((job) => {
|
|
78
|
+
fields(job, ['id', 'quiet', 'polled', 'synthetic', 'wantsAck', 'resetSessionAfter',
|
|
79
|
+
'emptyRetried', 'freshRetried', 'firstResumed', 'failedOver'], 'queue job', { optional: ['batch'] })
|
|
80
|
+
const out = { id: token(job.id, JOB_ID, 'queue job ID') }
|
|
81
|
+
ids.push(out.id)
|
|
82
|
+
for (const key of ['quiet', 'polled', 'synthetic', 'wantsAck', 'resetSessionAfter',
|
|
83
|
+
'emptyRetried', 'freshRetried', 'firstResumed', 'failedOver']) out[key] = bool(job[key], `queue ${key}`)
|
|
84
|
+
if (Object.hasOwn(job, 'batch')) {
|
|
85
|
+
out.batch = boundedArray(job.batch, MAX_MESSAGES, 'queue batch').map((member) => {
|
|
86
|
+
fields(member, ['id', 'synthetic', 'wantsAck', 'resetSessionAfter'], 'queue member')
|
|
87
|
+
const id = token(member.id, MEMBER_ID, 'queue member ID')
|
|
88
|
+
ids.push(id)
|
|
89
|
+
return { id, synthetic: bool(member.synthetic, 'member synthetic'),
|
|
90
|
+
wantsAck: bool(member.wantsAck, 'member wantsAck'),
|
|
91
|
+
resetSessionAfter: bool(member.resetSessionAfter, 'member resetSessionAfter') }
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
return out
|
|
95
|
+
})
|
|
96
|
+
unique(ids, 'queue')
|
|
97
|
+
return { queue }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function lifecycleLane(raw) {
|
|
101
|
+
const names = ['turnId', 'busy', 'forceReleaseAvailable', 'controller', 'reported', 'settled',
|
|
102
|
+
'released', 'hardTimerArmed', 'stopRequested', 'recalled', 'interrupt',
|
|
103
|
+
'turnTextHandle', 'msgIds', 'cancelled', 'lateReports']
|
|
104
|
+
fields(raw, [], 'lifecycle lane', { optional: names })
|
|
105
|
+
const out = {}
|
|
106
|
+
for (const key of names) {
|
|
107
|
+
if (!Object.hasOwn(raw, key)) continue
|
|
108
|
+
if (key === 'turnId') out[key] = nullable(raw[key], v => integer(v, 'turn ID'))
|
|
109
|
+
else if (key === 'turnTextHandle') out[key] = token(raw[key], TEXT_HANDLE, 'turn text handle')
|
|
110
|
+
else if (key === 'msgIds') {
|
|
111
|
+
out[key] = nullable(raw[key], v => unique(boundedArray(v, MAX_MESSAGES, 'message IDs')
|
|
112
|
+
.map(id => token(id, MESSAGE_ID, 'message ID')), 'message IDs'))
|
|
113
|
+
} else if (key === 'cancelled') {
|
|
114
|
+
out[key] = nullable(raw[key], v => {
|
|
115
|
+
fields(v, ['how', 'turnTextHandle'], 'cancellation')
|
|
116
|
+
if (!['/stop', '/skip', '控制台停止', '发送者撤回了消息'].includes(v.how)) {
|
|
117
|
+
throw new TypeError('cancellation reason invalid')
|
|
118
|
+
}
|
|
119
|
+
return { how: v.how, turnTextHandle: token(v.turnTextHandle, TEXT_HANDLE, 'cancellation handle') }
|
|
120
|
+
})
|
|
121
|
+
} else if (key === 'lateReports') {
|
|
122
|
+
plain(raw[key], 'late reports')
|
|
123
|
+
const entries = Object.entries(raw[key])
|
|
124
|
+
if (entries.length > MAX_LATE_REPORTS) throw new TypeError('late reports too large')
|
|
125
|
+
out[key] = Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b)).map(([id, value]) => {
|
|
126
|
+
if (!/^(0|[1-9][0-9]{0,15})$/.test(id) || Number(id) > SAFE_INTEGER || value !== true) {
|
|
127
|
+
throw new TypeError('late report entry invalid')
|
|
128
|
+
}
|
|
129
|
+
return [id, true]
|
|
130
|
+
}))
|
|
131
|
+
} else out[key] = bool(raw[key], `lifecycle ${key}`)
|
|
132
|
+
}
|
|
133
|
+
if (out.busy && !out.turnTextHandle) throw new TypeError('busy lifecycle lacks text handle')
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function turnLane(raw) {
|
|
138
|
+
fields(raw, ['epoch', 'active'], 'turn lane')
|
|
139
|
+
const epoch = integer(raw.epoch, 'turn epoch')
|
|
140
|
+
const active = nullable(raw.active, v => integer(v, 'active turn', { positive: true }))
|
|
141
|
+
if (active !== null && active !== epoch) throw new TypeError('active turn conflicts with epoch')
|
|
142
|
+
return { epoch, active }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function terminalLane(raw) {
|
|
146
|
+
fields(raw, ['finalized', 'outcome'], 'terminal lane',
|
|
147
|
+
{ optional: ['finalEventId', 'pendingEffectIds', 'batchReceiptIds',
|
|
148
|
+
'replyTargetReceiptId'] })
|
|
149
|
+
const finalized = bool(raw.finalized, 'terminal finalized')
|
|
150
|
+
if (!finalized) {
|
|
151
|
+
if (raw.outcome !== null || Object.hasOwn(raw, 'finalEventId') ||
|
|
152
|
+
Object.hasOwn(raw, 'pendingEffectIds') ||
|
|
153
|
+
Object.hasOwn(raw, 'batchReceiptIds') ||
|
|
154
|
+
Object.hasOwn(raw, 'replyTargetReceiptId')) throw new TypeError('unfinalized terminal outcome invalid')
|
|
155
|
+
return { finalized, outcome: null }
|
|
156
|
+
}
|
|
157
|
+
if (!Object.hasOwn(raw, 'finalEventId') || !Object.hasOwn(raw, 'pendingEffectIds')) {
|
|
158
|
+
throw new TypeError('finalized terminal lacks effect identity')
|
|
159
|
+
}
|
|
160
|
+
const keys = ['status', 'silent', 'nothingProduced', 'sideEffects', 'sessionMissing',
|
|
161
|
+
'lostRetry', 'network', 'quotaError', 'resumed']
|
|
162
|
+
fields(raw.outcome, keys, 'terminal outcome')
|
|
163
|
+
if (!['ok', 'no-reply', 'error', 'empty', 'stopped'].includes(raw.outcome.status)) {
|
|
164
|
+
throw new TypeError('terminal status invalid')
|
|
165
|
+
}
|
|
166
|
+
const outcome = { status: raw.outcome.status }
|
|
167
|
+
for (const key of keys.slice(1)) outcome[key] = bool(raw.outcome[key], `terminal ${key}`)
|
|
168
|
+
const finalEventId = token(raw.finalEventId, EVENT_ID, 'terminal final event ID')
|
|
169
|
+
const pendingEffectIds = boundedArray(raw.pendingEffectIds, 4, 'terminal pending effects')
|
|
170
|
+
.map((effectId, index) => {
|
|
171
|
+
const expected = `sbot-c2-effect-v1:${Buffer.byteLength(finalEventId)}:${finalEventId}:${index}`
|
|
172
|
+
if (effectId !== expected) throw new TypeError('terminal effect ID mismatch')
|
|
173
|
+
return effectId
|
|
174
|
+
})
|
|
175
|
+
const out = { finalized, outcome, finalEventId, pendingEffectIds }
|
|
176
|
+
if (Object.hasOwn(raw, 'batchReceiptIds')) {
|
|
177
|
+
const batch = unique(boundedArray(raw.batchReceiptIds, MAX_MESSAGES,
|
|
178
|
+
'terminal batch receipts').map(id => token(id, HEX_RECEIPT,
|
|
179
|
+
'terminal batch receipt ID')), 'terminal batch receipts')
|
|
180
|
+
if (batch.length < 2) throw new TypeError('terminal batch receipts too small')
|
|
181
|
+
out.batchReceiptIds = batch
|
|
182
|
+
const target = token(raw.replyTargetReceiptId, HEX_RECEIPT,
|
|
183
|
+
'terminal batch reply target')
|
|
184
|
+
if (!batch.includes(target)) {
|
|
185
|
+
throw new TypeError('terminal batch reply target is not a member')
|
|
186
|
+
}
|
|
187
|
+
out.replyTargetReceiptId = target
|
|
188
|
+
} else if (Object.hasOwn(raw, 'replyTargetReceiptId')) {
|
|
189
|
+
throw new TypeError('single receipt terminal has batch reply target')
|
|
190
|
+
}
|
|
191
|
+
return out
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function handoffLane(raw) {
|
|
195
|
+
fields(raw, ['phase', 'turnToken', 'attempt', 'acceptedReceiptIds', 'nextJobId'], 'handoff lane')
|
|
196
|
+
if (!['idle', 'running', 'releasing', 'handoff', 'sealed'].includes(raw.phase)) {
|
|
197
|
+
throw new TypeError('handoff phase invalid')
|
|
198
|
+
}
|
|
199
|
+
const turnToken = nullable(raw.turnToken, v => integer(v, 'handoff turn token', { positive: true }))
|
|
200
|
+
const attempt = integer(raw.attempt, 'handoff attempt')
|
|
201
|
+
if (attempt > 1) throw new TypeError('handoff attempt invalid')
|
|
202
|
+
const acceptedReceiptIds = unique(boundedArray(raw.acceptedReceiptIds, 1024, 'accepted receipts')
|
|
203
|
+
.map(id => token(id, HEX_RECEIPT, 'accepted receipt ID')), 'accepted receipts')
|
|
204
|
+
// The handoff reducer carries the receipt token, while the queue lane
|
|
205
|
+
// stores the same token behind the receipt:job: handle prefix.
|
|
206
|
+
const nextJobId = nullable(raw.nextJobId, v => token(v, HEX_RECEIPT, 'next job ID'))
|
|
207
|
+
if (raw.phase !== 'idle' && turnToken === null ||
|
|
208
|
+
!['releasing', 'sealed', 'handoff', 'running'].includes(raw.phase) &&
|
|
209
|
+
(attempt || acceptedReceiptIds.length) ||
|
|
210
|
+
raw.phase === 'handoff' && (attempt || acceptedReceiptIds.length > MAX_MESSAGES ||
|
|
211
|
+
acceptedReceiptIds.length && !acceptedReceiptIds.includes(nextJobId)) ||
|
|
212
|
+
raw.phase === 'running' && (attempt ||
|
|
213
|
+
acceptedReceiptIds.length === 1 || acceptedReceiptIds.length > MAX_MESSAGES) ||
|
|
214
|
+
(raw.phase === 'handoff') !== (nextJobId !== null)) throw new TypeError('handoff fields conflict')
|
|
215
|
+
return { phase: raw.phase, turnToken, attempt, acceptedReceiptIds, nextJobId }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function intakeLane(raw) {
|
|
219
|
+
fields(raw, ['watermark', 'seen', 'unboundNotified', 'pending'], 'intake lane')
|
|
220
|
+
const watermark = integer(raw.watermark, 'intake watermark')
|
|
221
|
+
const seen = boundedArray(raw.seen, 8192, 'intake seen').map(item => {
|
|
222
|
+
fields(item, ['key', 'at'], 'intake seen item')
|
|
223
|
+
return { key: token(item.key, HEX_RECEIPT, 'intake key'),
|
|
224
|
+
at: integer(item.at, 'intake seen time', { positive: true }) }
|
|
225
|
+
})
|
|
226
|
+
const keys = seen.map(item => item.key)
|
|
227
|
+
unique(keys, 'intake seen')
|
|
228
|
+
const pending = nullable(raw.pending, item => {
|
|
229
|
+
fields(item, ['key', 'at', 'watermark'], 'intake pending')
|
|
230
|
+
const value = { key: token(item.key, HEX_RECEIPT, 'intake pending key'),
|
|
231
|
+
at: integer(item.at, 'intake pending time', { positive: true }),
|
|
232
|
+
watermark: integer(item.watermark, 'intake pending watermark') }
|
|
233
|
+
if (value.watermark < watermark) throw new TypeError('intake pending watermark regressed')
|
|
234
|
+
return value
|
|
235
|
+
})
|
|
236
|
+
return { watermark, seen, unboundNotified: bool(raw.unboundNotified, 'intake unbound flag'), pending }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function sessionLane(raw) {
|
|
240
|
+
fields(raw, ['epoch', 'closeSeen', 'settled'], 'session lane')
|
|
241
|
+
const epoch = integer(raw.epoch, 'session epoch')
|
|
242
|
+
const closeSeen = bool(raw.closeSeen, 'session close flag')
|
|
243
|
+
const settled = bool(raw.settled, 'session settled flag')
|
|
244
|
+
if (settled && !closeSeen) throw new TypeError('session settled before close')
|
|
245
|
+
return { epoch, closeSeen, settled }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function stopIntentLane(raw) {
|
|
249
|
+
fields(raw, ['phase', 'controlReceiptId', 'runningReceiptId', 'turnToken',
|
|
250
|
+
'runnerGeneration'], 'stop intent lane', { optional: ['batchReceiptIds'] })
|
|
251
|
+
if (raw.phase !== 'intent') throw new TypeError('stop intent phase invalid')
|
|
252
|
+
const controlReceiptId = token(raw.controlReceiptId, HEX_RECEIPT, 'stop control receipt')
|
|
253
|
+
const runningReceiptId = token(raw.runningReceiptId, HEX_RECEIPT, 'stop running receipt')
|
|
254
|
+
if (controlReceiptId === runningReceiptId) throw new TypeError('stop receipts must differ')
|
|
255
|
+
const batchReceiptIds = Object.hasOwn(raw, 'batchReceiptIds')
|
|
256
|
+
? unique(boundedArray(raw.batchReceiptIds, MAX_MESSAGES,
|
|
257
|
+
'stop batch receipts').map(id => token(id, HEX_RECEIPT,
|
|
258
|
+
'stop batch receipt')), 'stop batch receipts')
|
|
259
|
+
: null
|
|
260
|
+
if (batchReceiptIds && (batchReceiptIds.length < 2 ||
|
|
261
|
+
!batchReceiptIds.includes(runningReceiptId) ||
|
|
262
|
+
batchReceiptIds.includes(controlReceiptId))) {
|
|
263
|
+
throw new TypeError('stop batch receipts invalid')
|
|
264
|
+
}
|
|
265
|
+
return { phase: 'intent', controlReceiptId, runningReceiptId,
|
|
266
|
+
turnToken: integer(raw.turnToken, 'stop turn token', { positive: true }),
|
|
267
|
+
runnerGeneration: token(raw.runnerGeneration, HEX_RECEIPT, 'stop runner generation'),
|
|
268
|
+
...(batchReceiptIds ? { batchReceiptIds } : {}) }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function cardCleanupLane(raw) {
|
|
272
|
+
fields(raw, ['phase', 'receiptId', 'turnToken', 'runnerGeneration',
|
|
273
|
+
'cardTargetDigest', 'deleteOperationId', 'cleanupEffectId',
|
|
274
|
+
'cardOperations'], 'card cleanup lane')
|
|
275
|
+
if (!['intent', 'acked'].includes(raw.phase)) {
|
|
276
|
+
throw new TypeError('card cleanup phase invalid')
|
|
277
|
+
}
|
|
278
|
+
const receiptId = token(raw.receiptId, HEX_RECEIPT, 'card cleanup receipt')
|
|
279
|
+
const turnToken = integer(raw.turnToken, 'card cleanup turn token', { positive: true })
|
|
280
|
+
const runnerGeneration = token(raw.runnerGeneration, HEX_RECEIPT,
|
|
281
|
+
'card cleanup generation')
|
|
282
|
+
const cardTargetDigest = token(raw.cardTargetDigest, HEX_DIGEST,
|
|
283
|
+
'card cleanup target digest')
|
|
284
|
+
const deleteOperationId = token(raw.deleteOperationId, HEX_DIGEST,
|
|
285
|
+
'card cleanup delete operation')
|
|
286
|
+
const eventId = `sbot-c2-card-v1:${receiptId}:${turnToken}:${deleteOperationId}`
|
|
287
|
+
const expectedEffectId = `sbot-c2-effect-v1:${Buffer.byteLength(eventId)}:${eventId}:0`
|
|
288
|
+
if (raw.cleanupEffectId !== expectedEffectId) {
|
|
289
|
+
throw new TypeError('card cleanup effect identity invalid')
|
|
290
|
+
}
|
|
291
|
+
return { phase: raw.phase, receiptId, turnToken, runnerGeneration,
|
|
292
|
+
cardTargetDigest, deleteOperationId, cleanupEffectId: expectedEffectId,
|
|
293
|
+
cardOperations: integer(raw.cardOperations, 'prior card operation count',
|
|
294
|
+
{ positive: true }) }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const LANE_VALIDATORS = Object.freeze({
|
|
298
|
+
queue: queueLane,
|
|
299
|
+
lifecycle: lifecycleLane,
|
|
300
|
+
turn: turnLane,
|
|
301
|
+
terminal: terminalLane,
|
|
302
|
+
handoff: handoffLane,
|
|
303
|
+
intake: intakeLane,
|
|
304
|
+
session: sessionLane,
|
|
305
|
+
stopIntent: stopIntentLane,
|
|
306
|
+
cardCleanup: cardCleanupLane,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
function projectPersistentState(state) {
|
|
310
|
+
plain(state, 'C2 state')
|
|
311
|
+
if (state.version !== 2) throw new TypeError('only C2 v2 state may be persisted')
|
|
312
|
+
const seq = integer(state.seq, 'C2 cursor')
|
|
313
|
+
fields(state, ['version', 'seq', 'lanes'], 'C2 state', { optional: seq ? ['lastEventId'] : [] })
|
|
314
|
+
if (seq && !Object.hasOwn(state, 'lastEventId')) throw new TypeError('last event ID missing')
|
|
315
|
+
const out = { version: 2, seq, lanes: {} }
|
|
316
|
+
if (seq) out.lastEventId = token(state.lastEventId, EVENT_ID, 'last event ID')
|
|
317
|
+
plain(state.lanes, 'C2 lanes')
|
|
318
|
+
for (const domain of Object.keys(state.lanes).sort()) {
|
|
319
|
+
const validator = LANE_VALIDATORS[domain]
|
|
320
|
+
if (!validator) throw new TypeError(`C2 lane ${domain} is not approved for persistence`)
|
|
321
|
+
const scopes = plain(state.lanes[domain], `${domain} scopes`)
|
|
322
|
+
const entries = Object.entries(scopes)
|
|
323
|
+
if (entries.length > MAX_SCOPES) throw new TypeError(`${domain} has too many scopes`)
|
|
324
|
+
const result = {}
|
|
325
|
+
for (const [scope, value] of entries.sort(([a], [b]) => a.localeCompare(b))) {
|
|
326
|
+
const key = token(scope, SOURCE_ID, `${domain} scope`)
|
|
327
|
+
if (['__proto__', 'constructor', 'prototype'].includes(key)) throw new TypeError('C2 scope invalid')
|
|
328
|
+
result[key] = validator(value)
|
|
329
|
+
}
|
|
330
|
+
out.lanes[domain] = result
|
|
331
|
+
}
|
|
332
|
+
if (Buffer.byteLength(JSON.stringify(out)) > MAX_STATE_BYTES) throw new TypeError('C2 durable state too large')
|
|
333
|
+
return out
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function validatePersistentState(state) {
|
|
337
|
+
const projected = projectPersistentState(state)
|
|
338
|
+
if (!isDeepStrictEqual(state, projected)) throw new TypeError('C2 durable state is not canonical')
|
|
339
|
+
return true
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
module.exports = { projectPersistentState, validatePersistentState }
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { chatPlatform } = require('../config')
|
|
4
|
+
|
|
5
|
+
const RECEIPT_ID = /^[0-9a-f]{32}$/
|
|
6
|
+
const FEISHU_CHAT_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
|
|
7
|
+
const FEISHU_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
|
|
8
|
+
const NOTICE_TEXT = '上一轮因服务中断未完成,请重新发送原请求。'
|
|
9
|
+
|
|
10
|
+
// A deliberately narrow recovery sender for a previously accepted Feishu
|
|
11
|
+
// message. The ordinary platform.reply() can fall back to a second send after
|
|
12
|
+
// an ambiguous SDK failure; startup recovery may make exactly one outward
|
|
13
|
+
// attempt, so use the same SDK's reply operation directly and never fall back.
|
|
14
|
+
// The caller supplies the active app's SDK client and the multi-platform
|
|
15
|
+
// router under its single-writer lease. Neither credentials nor reply text
|
|
16
|
+
// enter the durable receipt.
|
|
17
|
+
function createFeishuRecoverySender({ cfg, platform, client } = {}) {
|
|
18
|
+
if (!cfg || typeof cfg !== 'object' || !cfg.groups ||
|
|
19
|
+
!platform || typeof platform.names !== 'function' ||
|
|
20
|
+
typeof platform.get !== 'function' || typeof platform.forChat !== 'function' ||
|
|
21
|
+
!client || typeof client.im?.message?.reply !== 'function' ||
|
|
22
|
+
!platform.names().includes('feishu') || !platform.get('feishu')) {
|
|
23
|
+
throw new TypeError('C2 Feishu recovery needs active group routing and SDK client')
|
|
24
|
+
}
|
|
25
|
+
const feishu = platform.get('feishu')
|
|
26
|
+
|
|
27
|
+
function validReceipt(receipt) {
|
|
28
|
+
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt) ||
|
|
29
|
+
!RECEIPT_ID.test(receipt.id || '') ||
|
|
30
|
+
!FEISHU_CHAT_ID.test(receipt.chatId || '') ||
|
|
31
|
+
receipt.source !== 'message' ||
|
|
32
|
+
!FEISHU_MESSAGE_ID.test(receipt.sourceId || '')) {
|
|
33
|
+
throw new TypeError('C2 Feishu recovery receipt identity invalid')
|
|
34
|
+
}
|
|
35
|
+
const group = cfg.groups[receipt.chatId]
|
|
36
|
+
if (!group || chatPlatform(cfg, receipt.chatId) !== 'feishu' ||
|
|
37
|
+
platform.get('feishu') !== feishu || platform.forChat(receipt.chatId) !== feishu) {
|
|
38
|
+
throw new Error('C2 Feishu recovery group route is unavailable')
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveTarget(receipt) {
|
|
43
|
+
validReceipt(receipt)
|
|
44
|
+
return Object.freeze({ receiptId: receipt.id, chatId: receipt.chatId,
|
|
45
|
+
sourceId: receipt.sourceId,
|
|
46
|
+
replyCtx: Object.freeze({ chatId: receipt.chatId, messageId: receipt.sourceId }) })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function send(receipt, target) {
|
|
50
|
+
validReceipt(receipt)
|
|
51
|
+
if (!target || typeof target !== 'object' || Array.isArray(target) ||
|
|
52
|
+
target.receiptId !== receipt.id || target.chatId !== receipt.chatId ||
|
|
53
|
+
target.sourceId !== receipt.sourceId ||
|
|
54
|
+
target.replyCtx?.chatId !== receipt.chatId ||
|
|
55
|
+
target.replyCtx?.messageId !== receipt.sourceId) {
|
|
56
|
+
throw new Error('C2 Feishu recovery target differs from durable receipt')
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const response = await client.im.message.reply({
|
|
60
|
+
path: { message_id: receipt.sourceId },
|
|
61
|
+
data: { msg_type: 'text', content: JSON.stringify({ text: NOTICE_TEXT }) },
|
|
62
|
+
})
|
|
63
|
+
const id = response?.data?.message_id ?? response?.message_id
|
|
64
|
+
return response?.code === 0 && typeof id === 'string' &&
|
|
65
|
+
FEISHU_MESSAGE_ID.test(id) ? id : null
|
|
66
|
+
} catch {
|
|
67
|
+
// The SDK call may have reached the platform. The recovery gate records
|
|
68
|
+
// a null outcome as unknown and will never retry it on another startup.
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return Object.freeze({ resolveTarget, send })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { createFeishuRecoverySender }
|