sintbot 1.0.16 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.en.json +99 -0
  2. package/CHANGELOG.json +99 -0
  3. package/README.md +20 -158
  4. package/README.zh-CN.md +20 -166
  5. package/THIRD-PARTY-NOTICES.md +394 -0
  6. package/bin/sintbot-v2.js +363 -0
  7. package/docs/AI-ASSISTED-INSTALL.md +11 -35
  8. package/docs/install.md +61 -167
  9. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
  10. package/docs/zh-CN/install.md +61 -165
  11. package/install.sh +84 -0
  12. package/package.json +73 -37
  13. package/src/agent/claudecode.js +149 -108
  14. package/src/agent/codexapp.js +458 -0
  15. package/src/agent/codexcode.js +210 -107
  16. package/src/agent/errtag.js +34 -0
  17. package/src/agent/privatecore.js +790 -0
  18. package/src/agent/privatecore.wasm +0 -0
  19. package/src/agent/runner.js +10 -8
  20. package/src/agent/winexec.js +35 -5
  21. package/src/c2install.js +117 -0
  22. package/src/config.js +2 -1
  23. package/src/core/c2cardeffectbridge.js +51 -0
  24. package/src/core/c2cardterminalpath.js +216 -0
  25. package/src/core/c2durableingress.js +148 -0
  26. package/src/core/c2durablejournal.js +1560 -0
  27. package/src/core/c2durableruntime.js +2361 -0
  28. package/src/core/c2eventidentity.js +90 -0
  29. package/src/core/c2globalfault.js +401 -0
  30. package/src/core/c2localeffectexecutor.js +193 -0
  31. package/src/core/c2payloadhandles.js +333 -0
  32. package/src/core/c2persistrelation.js +179 -0
  33. package/src/core/c2persiststate.js +342 -0
  34. package/src/core/c2platformrecoverysender.js +76 -0
  35. package/src/core/c2recoverygate.js +385 -0
  36. package/src/core/c2runnerinput.js +63 -0
  37. package/src/core/c2servicestartgate.js +103 -0
  38. package/src/core/c2sessionbridge.js +72 -0
  39. package/src/core/c2sourcequarantine.js +30 -0
  40. package/src/core/c2startuprecovery.js +105 -0
  41. package/src/core/c2successorbridge.js +151 -0
  42. package/src/core/c2terminaleffectbridge.js +154 -0
  43. package/src/core/c2terminalprune.js +108 -0
  44. package/src/core/c2turnprojection.js +177 -0
  45. package/src/core/enginecheckpoint.js +211 -0
  46. package/src/core/engineeffectcursor.js +394 -0
  47. package/src/core/enginestepcoordinator.js +183 -0
  48. package/src/core/store.js +52 -8
  49. package/src/core/v2engine.js +1469 -0
  50. package/src/platform/feishu.js +324 -67
  51. package/src/plugins.js +1 -9
  52. package/assets/design-styles/acid-pop.md +0 -155
  53. package/assets/design-styles/archive-index.md +0 -161
  54. package/assets/design-styles/art-deco.md +0 -164
  55. package/assets/design-styles/cinematic-noir.md +0 -178
  56. package/assets/design-styles/editorial.md +0 -121
  57. package/assets/design-styles/extra/README.md +0 -23
  58. package/assets/design-styles/extra/constructivism.md +0 -146
  59. package/assets/design-styles/extra/neo-brutalism.md +0 -175
  60. package/assets/design-styles/extra/new-wave.md +0 -166
  61. package/assets/design-styles/extra/nordic-material.md +0 -162
  62. package/assets/design-styles/extra/paris-couture.md +0 -162
  63. package/assets/design-styles/extra/tropical-modern.md +0 -168
  64. package/assets/design-styles/extra/ulm-functional.md +0 -182
  65. package/assets/design-styles/extra/wa-shikimen.md +0 -160
  66. package/assets/design-styles/extra/y2k-chrome.md +0 -172
  67. package/assets/design-styles/geometric-pop.md +0 -122
  68. package/assets/design-styles/hand-drawn.md +0 -117
  69. package/assets/design-styles/islamic-geometry.md +0 -155
  70. package/assets/design-styles/liquid-glass.md +0 -183
  71. package/assets/design-styles/neo-chinese.md +0 -136
  72. package/assets/design-styles/precision-mono.md +0 -156
  73. package/assets/design-styles/quiet-luxury.md +0 -163
  74. package/assets/design-styles/riso-print.md +0 -116
  75. package/assets/design-styles/soft-friendly.md +0 -174
  76. package/assets/design-styles/swiss-grid.md +0 -156
  77. package/assets/design-styles/tech-dark.md +0 -182
  78. package/assets/favicon.svg +0 -6
  79. package/assets/logo.svg +0 -24
  80. package/bin/sbot.js +0 -4716
  81. package/docs/slack-app-manifest.yaml +0 -104
  82. package/sbot.config.example.json +0 -235
  83. package/src/agent/askonce.js +0 -76
  84. package/src/agent/claudemodels.js +0 -60
  85. package/src/agent/claudestatus.js +0 -74
  86. package/src/agent/claudeusage.js +0 -200
  87. package/src/agent/codexstatus.js +0 -181
  88. package/src/agent/imagen.js +0 -458
  89. package/src/agent/quotaerr.js +0 -44
  90. package/src/agent/transienterr.js +0 -32
  91. package/src/cdp.js +0 -254
  92. package/src/cli-secret.js +0 -36
  93. package/src/clilang.js +0 -31
  94. package/src/core/cron.js +0 -182
  95. package/src/core/cronfire.js +0 -120
  96. package/src/core/cronrun.js +0 -116
  97. package/src/core/engine.js +0 -2758
  98. package/src/core/interfaces.js +0 -114
  99. package/src/core/issues.js +0 -185
  100. package/src/core/ledger.js +0 -30
  101. package/src/core/opsalert.js +0 -214
  102. package/src/core/pending.js +0 -123
  103. package/src/core/proxy.js +0 -424
  104. package/src/core/quotaalert.js +0 -79
  105. package/src/core/quotaerr.js +0 -55
  106. package/src/core/scheduler.js +0 -59
  107. package/src/design.js +0 -5161
  108. package/src/gitrepo.js +0 -150
  109. package/src/health.js +0 -353
  110. package/src/installdeps.js +0 -72
  111. package/src/leftover.js +0 -174
  112. package/src/platform/boardnotice.js +0 -77
  113. package/src/platform/boardops.js +0 -36
  114. package/src/platform/boardwatch.js +0 -447
  115. package/src/platform/cli.js +0 -45
  116. package/src/platform/slack.js +0 -1289
  117. package/src/platform/takeovercard.js +0 -37
  118. package/src/platform/wecom.js +0 -938
  119. package/src/platform/wecomcli.js +0 -128
  120. package/src/platform/wecomgate.js +0 -108
  121. package/src/platform/wecomname.js +0 -43
  122. package/src/platform/wecompoll.js +0 -241
  123. package/src/preview.js +0 -581
  124. package/src/projectmemory.js +0 -175
  125. package/src/service.js +0 -317
  126. package/src/ui/auth.js +0 -31
  127. package/src/ui/browser.html +0 -392
  128. package/src/ui/browserroutes.js +0 -234
  129. package/src/ui/console.html +0 -5526
  130. package/src/ui/dashboard.js +0 -168
  131. package/src/ui/feishulogin.js +0 -136
  132. package/src/ui/server.js +0 -2314
  133. package/src/ui/sessions.js +0 -78
  134. package/src/ui/takeover.js +0 -108
  135. package/src/updater.js +0 -243
  136. package/src/upgrade.js +0 -35
  137. 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 }