@aspalagin/openclaw-max 0.7.3 → 0.8.1

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 (85) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +412 -344
  3. package/README_EN.md +492 -0
  4. package/SKILL.md +45 -37
  5. package/assets/icon.png +0 -0
  6. package/dist/secret-contract-api.js +6 -0
  7. package/dist/src/access-policy.js +123 -0
  8. package/dist/src/accounts.js +170 -23
  9. package/dist/src/action-scope.js +213 -0
  10. package/dist/src/actions.js +159 -31
  11. package/dist/src/api.js +99 -83
  12. package/dist/src/callbacks.js +27 -7
  13. package/dist/src/channel-agent-prompt.js +8 -19
  14. package/dist/src/channel-config.js +25 -3
  15. package/dist/src/channel-lifecycle.js +28 -5
  16. package/dist/src/channel-outbound.js +43 -9
  17. package/dist/src/channel-policy.js +19 -6
  18. package/dist/src/channel.js +16 -0
  19. package/dist/src/command-menu.js +199 -0
  20. package/dist/src/config-schema.js +106 -10
  21. package/dist/src/deliver.js +144 -48
  22. package/dist/src/dispatch.js +12 -11
  23. package/dist/src/gateway-drain.js +40 -0
  24. package/dist/src/group-mention.js +129 -0
  25. package/dist/src/inbound-attachments.js +162 -20
  26. package/dist/src/inbound.js +388 -134
  27. package/dist/src/media-temp.js +31 -20
  28. package/dist/src/network.js +216 -0
  29. package/dist/src/onboarding.js +4 -2
  30. package/dist/src/polling.js +180 -12
  31. package/dist/src/presentation.js +11 -0
  32. package/dist/src/progress-draft.js +168 -0
  33. package/dist/src/runtime.js +15 -0
  34. package/dist/src/secret-contract.js +149 -0
  35. package/dist/src/send.js +250 -46
  36. package/dist/src/stream-draft.js +83 -41
  37. package/dist/src/turn-adoption.js +24 -0
  38. package/dist/src/typing.js +55 -0
  39. package/dist/src/update-journal.js +267 -0
  40. package/dist/src/webhook-queue.js +151 -26
  41. package/dist/src/webhook-runner.js +61 -9
  42. package/dist/src/webhook.js +34 -15
  43. package/openclaw.plugin.json +983 -8
  44. package/package.json +17 -4
  45. package/src/access-policy.ts +154 -0
  46. package/src/accounts.ts +260 -25
  47. package/src/action-scope.ts +274 -0
  48. package/src/actions.ts +188 -35
  49. package/src/api.ts +158 -101
  50. package/src/callbacks.ts +26 -7
  51. package/src/channel-agent-prompt.ts +8 -19
  52. package/src/channel-config.ts +32 -3
  53. package/src/channel-lifecycle.ts +33 -6
  54. package/src/channel-outbound.ts +50 -7
  55. package/src/channel-policy.ts +27 -7
  56. package/src/channel.ts +16 -0
  57. package/src/command-menu.ts +277 -0
  58. package/src/config-schema.ts +111 -10
  59. package/src/deliver.ts +174 -55
  60. package/src/dispatch.ts +12 -11
  61. package/src/gateway-drain.ts +45 -0
  62. package/src/group-mention.ts +191 -0
  63. package/src/inbound-attachments.ts +173 -22
  64. package/src/inbound.ts +443 -130
  65. package/src/media-temp.ts +35 -23
  66. package/src/network.ts +233 -0
  67. package/src/onboarding.ts +4 -2
  68. package/src/polling.ts +221 -15
  69. package/src/presentation.ts +14 -1
  70. package/src/progress-draft.ts +226 -0
  71. package/src/runtime.ts +23 -0
  72. package/src/secret-contract.ts +183 -0
  73. package/src/send.ts +346 -59
  74. package/src/stream-draft.ts +102 -41
  75. package/src/turn-adoption.ts +33 -0
  76. package/src/types.ts +9 -3
  77. package/src/typing.ts +77 -0
  78. package/src/update-journal.ts +302 -0
  79. package/src/webhook-queue.ts +205 -33
  80. package/src/webhook-runner.ts +65 -8
  81. package/src/webhook.ts +39 -18
  82. package/scripts/check-cycles.mjs +0 -38
  83. package/scripts/test-api.mjs +0 -169
  84. package/scripts/test-send.mjs +0 -162
  85. package/scripts/update-schema.mjs +0 -48
@@ -13,6 +13,9 @@
13
13
  */
14
14
 
15
15
  import type { MaxUpdate } from './api.js';
16
+ import { isMaxGatewayDrainingError, waitForMaxDrainRetry } from './gateway-drain.js';
17
+ import { runWithMaxTurnAdoption } from './turn-adoption.js';
18
+ import type { MaxJournalEntry, MaxUpdateJournal } from './update-journal.js';
16
19
 
17
20
  /** Updates handled at once across chats; one chat is always sequential. */
18
21
  export const MAX_WEBHOOK_CHAT_CONCURRENCY = 4;
@@ -23,12 +26,62 @@ export const MAX_WEBHOOK_STOP_GRACE_MS = 3000;
23
26
  /** How long a restarted consumer waits for its predecessor's in-flight dispatches. */
24
27
  export const MAX_WEBHOOK_HANDOVER_WAIT_MS = 60_000;
25
28
 
29
+ /** Default age past which updates the bot would answer are skipped (maxEventAgeMinutes). */
30
+ export const MAX_EVENT_MAX_AGE_MINUTES = 60;
31
+
32
+ /** Updates a late answer would confuse; registry updates (bot_added, …) are always handled. */
33
+ const AGE_FENCED_UPDATE_TYPES = new Set([
34
+ 'message_created',
35
+ 'message_edited',
36
+ 'message_callback',
37
+ 'bot_started',
38
+ ]);
39
+
40
+ /** maxEventAgeMinutes in ms; 0 (or less) turns the age fence off. */
41
+ export function resolveMaxEventMaxAgeMs(minutes: number | undefined): number {
42
+ const value = minutes ?? MAX_EVENT_MAX_AGE_MINUTES;
43
+ return value > 0 ? value * 60_000 : 0;
44
+ }
45
+
46
+ /**
47
+ * Age of an update the bot would answer, when it is past `maxAgeMs`: MAX's
48
+ * event time, else when it was received. Undefined when fresh or not fenced.
49
+ */
50
+ export function staleMaxUpdateAgeMs(
51
+ update: MaxUpdate,
52
+ maxAgeMs: number,
53
+ now: number,
54
+ receivedAt?: number,
55
+ ): number | undefined {
56
+ if (maxAgeMs <= 0 || !AGE_FENCED_UPDATE_TYPES.has(update.update_type)) return undefined;
57
+ const at = typeof update.timestamp === 'number' ? update.timestamp : receivedAt;
58
+ if (at === undefined) return undefined;
59
+ const age = now - at;
60
+ return age > maxAgeMs ? age : undefined;
61
+ }
62
+
63
+ /** The queue could not take an update: the route answers non-200 so MAX redelivers. */
64
+ export class MaxWebhookQueueRejectedError extends Error {
65
+ constructor(message: string) {
66
+ super(message);
67
+ this.name = 'MaxWebhookQueueRejectedError';
68
+ }
69
+ }
70
+
71
+ export type MaxWebhookAdmitResult = 'queued' | 'duplicate' | 'stale' | 'dropped';
72
+
26
73
  /** Serialization lane: the chat an update belongs to. */
27
74
  export function maxWebhookUpdateLane(update: MaxUpdate): string {
28
75
  return String(update.message?.recipient?.chat_id ?? update.chat_id ?? 'global');
29
76
  }
30
77
 
31
- type Handover = { updates: MaxUpdate[]; settled: Promise<void> };
78
+ /**
79
+ * An update in memory; `entry` is its journal row when the queue is durable,
80
+ * `refusals` counts core's refusals of it while the gateway drains.
81
+ */
82
+ type QueuedUpdate = { update: MaxUpdate; entry?: MaxJournalEntry; refusals?: number };
83
+
84
+ type Handover = { updates: QueuedUpdate[]; settled: Promise<void> };
32
85
 
33
86
  /**
34
87
  * Updates acked to MAX but not dispatched when an account task stopped
@@ -37,11 +90,24 @@ type Handover = { updates: MaxUpdate[]; settled: Promise<void> };
37
90
  */
38
91
  const handovers = new Map<string, Handover>();
39
92
  /** Open queues per account: a stopping task hands leftovers to an overlapping successor. */
40
- const openQueues = new Map<string, Set<(update: MaxUpdate) => void>>();
93
+ const openQueues = new Map<string, Set<(item: QueuedUpdate) => void>>();
41
94
 
42
95
  export type MaxWebhookUpdateQueue = {
43
- /** Called by the route handler (request context): enqueue only, never dispatch. */
96
+ /**
97
+ * Called by the route handler (request context) before it answers MAX:
98
+ * durably records (journal) and enqueues, never dispatches. Throws
99
+ * MaxWebhookQueueRejectedError (full, overflow "reject") or the journal
100
+ * write error — the route then answers 503 and MAX redelivers.
101
+ */
102
+ admit: (update: MaxUpdate, dedupeKey?: string) => Promise<MaxWebhookAdmitResult>;
103
+ /** In-memory enqueue (no journal); drops beyond the pending limit. */
44
104
  push: (update: MaxUpdate) => void;
105
+ /**
106
+ * Durable queue: take over entries an earlier process left in the journal,
107
+ * ahead of everything queued since. Stale ones (maxAgeMs) are completed
108
+ * without dispatch. Call before the route is registered.
109
+ */
110
+ recover: () => Promise<{ recovered: number; stale: number; unreadable: number }>;
45
111
  /**
46
112
  * Runs in the account task until abortSignal fires: dispatches updates in
47
113
  * order per chat, up to `concurrency` chats at once. Resolves after stop.
@@ -51,6 +117,7 @@ export type MaxWebhookUpdateQueue = {
51
117
  abortSignal: AbortSignal;
52
118
  onError: (err: unknown, update: MaxUpdate) => void;
53
119
  onWarn?: (message: string) => void;
120
+ onInfo?: (message: string) => void;
54
121
  }) => Promise<void>;
55
122
  /** Start failed before consume(): release the queue, keeping taken-over updates. */
56
123
  close: () => void;
@@ -62,8 +129,19 @@ export function createMaxWebhookUpdateQueue(params: {
62
129
  pendingLimit?: number;
63
130
  stopGraceMs?: number;
64
131
  handoverWaitMs?: number;
132
+ /** Durable mode: updates are journaled before the ack and survive restarts. */
133
+ journal?: MaxUpdateJournal;
134
+ /** Full queue: "reject" (default) answers MAX non-200 so it redelivers; "drop" acks and drops. */
135
+ overflow?: 'reject' | 'drop';
136
+ /** Skip updates the bot would answer when older than this; 0 = no limit. */
137
+ maxAgeMs?: number;
138
+ now?: () => number;
139
+ onWarn?: (message: string) => void;
65
140
  }): MaxWebhookUpdateQueue {
66
- const { accountId } = params;
141
+ const { accountId, journal } = params;
142
+ const overflow = params.overflow ?? 'reject';
143
+ const maxAgeMs = params.maxAgeMs ?? 0;
144
+ const now = params.now ?? Date.now;
67
145
  const concurrency = Math.max(1, params.concurrency ?? MAX_WEBHOOK_CHAT_CONCURRENCY);
68
146
  const pendingLimit = params.pendingLimit ?? MAX_WEBHOOK_PENDING_LIMIT;
69
147
  const stopGraceMs = params.stopGraceMs ?? MAX_WEBHOOK_STOP_GRACE_MS;
@@ -72,42 +150,97 @@ export function createMaxWebhookUpdateQueue(params: {
72
150
  // Taken over at creation: updates pushed later must queue behind them.
73
151
  const previous = handovers.get(accountId);
74
152
  handovers.delete(accountId);
75
- const inbox: MaxUpdate[] = previous ? [...previous.updates] : [];
153
+ const inbox: QueuedUpdate[] = previous ? [...previous.updates] : [];
76
154
  let pending = inbox.length;
77
155
  let closed = false;
78
156
  let wake: (() => void) | undefined;
79
- let warn: ((message: string) => void) | undefined;
157
+ let warn: ((message: string) => void) | undefined = params.onWarn;
80
158
 
81
- const push = (update: MaxUpdate): void => {
159
+ const enqueue = (item: QueuedUpdate): void => {
82
160
  if (closed) {
83
161
  // Route still selected this target while the task was stopping.
84
162
  const successor = [...siblings].at(-1);
85
- if (successor) return successor(update);
163
+ if (successor) return successor(item);
86
164
  const handover = handovers.get(accountId);
87
- if (handover) handover.updates.push(update);
88
- else handovers.set(accountId, { updates: [update], settled: Promise.resolve() });
165
+ if (handover) handover.updates.push(item);
166
+ else handovers.set(accountId, { updates: [item], settled: Promise.resolve() });
89
167
  return;
90
168
  }
91
- if (pending >= pendingLimit) {
169
+ pending += 1;
170
+ inbox.push(item);
171
+ wake?.();
172
+ };
173
+ const siblings = openQueues.get(accountId) ?? new Set();
174
+ siblings.add(enqueue);
175
+ openQueues.set(accountId, siblings);
176
+
177
+ const push = (update: MaxUpdate): void => {
178
+ if (!closed && pending >= pendingLimit) {
92
179
  warn?.(
93
180
  `[${accountId}] MAX webhook queue full (${pendingLimit}); dropping ${update.update_type}`,
94
181
  );
95
182
  return;
96
183
  }
97
- pending += 1;
98
- inbox.push(update);
99
- wake?.();
184
+ enqueue({ update });
185
+ };
186
+
187
+ const admit: MaxWebhookUpdateQueue['admit'] = async (update, dedupeKey) => {
188
+ const staleMs = staleMaxUpdateAgeMs(update, maxAgeMs, now());
189
+ if (staleMs !== undefined) {
190
+ warn?.(
191
+ `[${accountId}] MAX webhook: ${update.update_type} from ${Math.round(staleMs / 60_000)} min ago skipped (older than maxEventAgeMinutes)`,
192
+ );
193
+ return 'stale';
194
+ }
195
+ if (journal && dedupeKey && journal.isDuplicate(dedupeKey)) return 'duplicate';
196
+ if (!closed && pending >= pendingLimit) {
197
+ if (overflow === 'reject') {
198
+ throw new MaxWebhookQueueRejectedError(
199
+ `MAX webhook queue full (${pendingLimit} pending); ${update.update_type} refused for redelivery`,
200
+ );
201
+ }
202
+ warn?.(
203
+ `[${accountId}] MAX webhook queue full (${pendingLimit}); dropping ${update.update_type}`,
204
+ );
205
+ return 'dropped';
206
+ }
207
+ const entry = journal ? await journal.append(update, dedupeKey) : undefined;
208
+ enqueue({ update, entry });
209
+ return 'queued';
210
+ };
211
+
212
+ const recover: MaxWebhookUpdateQueue['recover'] = async () => {
213
+ if (!journal) return { recovered: 0, stale: 0, unreadable: 0 };
214
+ const { entries, unreadable } = await journal.readPending();
215
+ const recovered: QueuedUpdate[] = [];
216
+ let stale = 0;
217
+ for (const entry of entries) {
218
+ if (staleMaxUpdateAgeMs(entry.update, maxAgeMs, now(), entry.receivedAt) !== undefined) {
219
+ stale += 1;
220
+ await journal.complete(entry);
221
+ continue;
222
+ }
223
+ recovered.push({ update: entry.update, entry });
224
+ }
225
+ if (recovered.length > 0) {
226
+ if (closed) {
227
+ for (const item of recovered) enqueue(item);
228
+ } else {
229
+ // An earlier process accepted these before anything queued here.
230
+ inbox.unshift(...recovered);
231
+ pending += recovered.length;
232
+ wake?.();
233
+ }
234
+ }
235
+ return { recovered: recovered.length, stale, unreadable };
100
236
  };
101
- const siblings = openQueues.get(accountId) ?? new Set();
102
- siblings.add(push);
103
- openQueues.set(accountId, siblings);
104
237
 
105
238
  /** Stop accepting and pass undispatched updates (lanes first, then inbox) on. */
106
- const close = (fromLanes: MaxUpdate[], settled: Promise<void>): void => {
239
+ const close = (fromLanes: QueuedUpdate[], settled: Promise<void>): void => {
107
240
  if (closed) return;
108
241
  closed = true;
109
242
  wake = undefined;
110
- siblings.delete(push);
243
+ siblings.delete(enqueue);
111
244
  if (siblings.size === 0 && openQueues.get(accountId) === siblings) {
112
245
  openQueues.delete(accountId);
113
246
  }
@@ -118,7 +251,7 @@ export function createMaxWebhookUpdateQueue(params: {
118
251
  const successor = [...siblings].at(-1);
119
252
  if (successor) {
120
253
  // A newer task of this account is already consuming: queue there.
121
- for (const update of leftover) successor(update);
254
+ for (const item of leftover) successor(item);
122
255
  warn?.(
123
256
  `[${accountId}] MAX webhook stopped with ${leftover.length} update(s) not dispatched; moved to the running successor`,
124
257
  );
@@ -136,33 +269,72 @@ export function createMaxWebhookUpdateQueue(params: {
136
269
  abortSignal,
137
270
  onError,
138
271
  onWarn,
272
+ onInfo,
139
273
  }) => {
140
- warn = onWarn;
141
- const lanes = new Map<string, MaxUpdate[]>();
274
+ if (onWarn) warn = onWarn;
275
+ const lanes = new Map<string, QueuedUpdate[]>();
142
276
  const ready: string[] = [];
143
277
  const active = new Set<string>();
144
278
  const inflight = new Set<Promise<void>>();
145
279
 
146
- const route = (update: MaxUpdate): void => {
147
- const lane = maxWebhookUpdateLane(update);
280
+ const route = (item: QueuedUpdate): void => {
281
+ const lane = maxWebhookUpdateLane(item.update);
148
282
  const queued = lanes.get(lane);
149
- if (queued) queued.push(update);
150
- else lanes.set(lane, [update]);
283
+ if (queued) queued.push(item);
284
+ else lanes.set(lane, [item]);
151
285
  if (!active.has(lane) && !ready.includes(lane)) ready.push(lane);
152
286
  };
153
287
 
288
+ /** Back to the head of its lane, ahead of the chat's later updates; after the stop, passed on. */
289
+ const requeueFirst = (lane: string, item: QueuedUpdate): void => {
290
+ if (closed) return enqueue(item);
291
+ pending += 1;
292
+ const queued = lanes.get(lane);
293
+ if (queued) queued.unshift(item);
294
+ else lanes.set(lane, [item]);
295
+ };
296
+
154
297
  const runLane = async (lane: string): Promise<void> => {
155
298
  try {
156
299
  for (;;) {
157
300
  if (abortSignal.aborted) return;
158
- const update = lanes.get(lane)?.shift();
159
- if (!update) return;
301
+ const item = lanes.get(lane)?.shift();
302
+ if (!item) return;
160
303
  pending -= 1;
304
+ const { entry } = item;
305
+ let completion: Promise<void> | undefined;
306
+ // At core's turn adoption (core then owns crash recovery) or when the
307
+ // dispatch returns, whichever is first. A failed turn is not replayed.
308
+ const complete = (): Promise<void> =>
309
+ (completion ??= entry && journal ? journal.complete(entry) : Promise.resolve());
161
310
  try {
162
- await dispatch(update);
311
+ await (entry
312
+ ? runWithMaxTurnAdoption(complete, () => dispatch(item.update))
313
+ : dispatch(item.update));
314
+ if (item.refusals) {
315
+ onInfo?.(
316
+ `[${accountId}] MAX webhook: ${item.update.update_type} accepted after ${item.refusals} refusal(s) of the draining gateway`,
317
+ );
318
+ }
163
319
  } catch (err) {
164
- onError(err, update);
320
+ // Refused by the draining gateway before core adopted the turn: the
321
+ // turn never started. The update stays at the head of its chat and
322
+ // is retried after a pause; a stop meanwhile hands it over with the
323
+ // leftovers and its journal row stays on disk for the next process.
324
+ if (entry && !completion && isMaxGatewayDrainingError(err)) {
325
+ item.refusals = (item.refusals ?? 0) + 1;
326
+ if (item.refusals === 1) {
327
+ warn?.(
328
+ `[${accountId}] MAX webhook: the gateway is draining and refused ${item.update.update_type}; kept queued, retried until accepted or the channel stops (then after the restart)`,
329
+ );
330
+ }
331
+ requeueFirst(lane, item);
332
+ await waitForMaxDrainRetry(item.refusals, abortSignal);
333
+ continue;
334
+ }
335
+ onError(err, item.update);
165
336
  }
337
+ await complete();
166
338
  }
167
339
  } finally {
168
340
  active.delete(lane);
@@ -200,14 +372,14 @@ export function createMaxWebhookUpdateQueue(params: {
200
372
  await waitAtMost(previous.settled, handoverWaitMs, abortSignal);
201
373
  }
202
374
  while (!abortSignal.aborted) {
203
- while (inbox.length > 0) route(inbox.shift() as MaxUpdate);
375
+ while (inbox.length > 0) route(inbox.shift() as QueuedUpdate);
204
376
  pump();
205
377
  await nextWake();
206
378
  wake = undefined;
207
379
  }
208
380
  } finally {
209
381
  abortSignal.removeEventListener('abort', onAbort);
210
- const leftover: MaxUpdate[] = [];
382
+ const leftover: QueuedUpdate[] = [];
211
383
  for (const queued of lanes.values()) leftover.push(...queued);
212
384
  lanes.clear();
213
385
  const settled = Promise.allSettled([...inflight]).then(() => undefined);
@@ -216,7 +388,7 @@ export function createMaxWebhookUpdateQueue(params: {
216
388
  }
217
389
  };
218
390
 
219
- return { push, consume, close: () => close([], Promise.resolve()) };
391
+ return { admit, push, recover, consume, close: () => close([], Promise.resolve()) };
220
392
  }
221
393
 
222
394
  /** Resolves when `promise` settles, `ms` elapse or `signal` aborts — whichever is first. */
@@ -18,14 +18,16 @@ import {
18
18
  type MaxStatusPatch,
19
19
  } from './monitor-types.js';
20
20
  import type { MaxStateStore } from './state.js';
21
+ import { type MaxUpdateJournal, openMaxUpdateJournal } from './update-journal.js';
21
22
  import {
23
+ maxUpdateDedupeKey,
22
24
  type MaxWebhookTarget,
23
25
  registerMaxWebhookRoute,
24
26
  registerMaxWebhookTarget,
25
27
  resolveMaxWebhookPath,
26
28
  subscribeMaxWebhook,
27
29
  } from './webhook.js';
28
- import { createMaxWebhookUpdateQueue } from './webhook-queue.js';
30
+ import { createMaxWebhookUpdateQueue, resolveMaxEventMaxAgeMs } from './webhook-queue.js';
29
31
 
30
32
  /** MAX secret format (SubscriptionRequestBody.secret): 5–256 of [A-Za-z0-9_-]. */
31
33
  const MAX_WEBHOOK_SECRET_PATTERN = /^[\w-]{5,256}$/;
@@ -48,6 +50,9 @@ export async function resolveMaxWebhookSecret(
48
50
  return secret;
49
51
  };
50
52
 
53
+ // A webhookSecret SecretRef that did not resolve never falls back to the
54
+ // file or a generated secret.
55
+ if (account.secretErrors?.webhookSecret) throw new Error(account.secretErrors.webhookSecret);
51
56
  const configured = account.config.webhookSecret?.trim();
52
57
  if (configured) return validate(configured, 'webhookSecret');
53
58
 
@@ -203,11 +208,34 @@ export async function startMaxWebhook(opts: MaxMonitorOptions): Promise<void> {
203
208
  } as MaxStatusPatch);
204
209
 
205
210
  // MAX requires HTTP 200 within 30s while agent runs regularly take minutes.
206
- // The route handler acks and only enqueues; this account task dispatches
207
- // (see webhook-queue.ts: dispatching from the request context fails with
208
- // GatewayDrainingError once the request's work admission is released).
209
- const queue = createMaxWebhookUpdateQueue({ accountId: account.accountId });
210
- const onUpdate = (update: MaxUpdate): Promise<void> => {
211
+ // The route handler records and enqueues before the ack; this account task
212
+ // dispatches (see webhook-queue.ts: dispatching from the request context
213
+ // fails with GatewayDrainingError once the request's work admission is
214
+ // released).
215
+ const journal = await openWebhookJournal(opts);
216
+ const queueConfig = account.config.webhookQueue;
217
+ const queue = createMaxWebhookUpdateQueue({
218
+ accountId: account.accountId,
219
+ journal,
220
+ pendingLimit: queueConfig?.maxPending,
221
+ overflow: queueConfig?.overflow,
222
+ maxAgeMs: resolveMaxEventMaxAgeMs(account.config.maxEventAgeMinutes),
223
+ onWarn: (message) => log?.warn(message),
224
+ });
225
+ if (journal) {
226
+ // Before the route exists: what an earlier process accepted goes first.
227
+ try {
228
+ const { recovered, stale, unreadable } = await queue.recover();
229
+ if (recovered + stale + unreadable > 0) {
230
+ log?.warn(
231
+ `[${account.accountId}] MAX webhook queue: ${recovered} update(s) accepted before the restart queued again, ${stale} stale skipped (maxEventAgeMinutes), ${unreadable} unreadable dropped`,
232
+ );
233
+ }
234
+ } catch (err) {
235
+ log?.error(`[${account.accountId}] MAX webhook queue recovery failed: ${String(err)}`);
236
+ }
237
+ }
238
+ const onUpdate = async (update: MaxUpdate): Promise<void> => {
211
239
  const at = Date.now();
212
240
  statusSink?.(
213
241
  channelReadyPatch({
@@ -216,8 +244,10 @@ export async function startMaxWebhook(opts: MaxMonitorOptions): Promise<void> {
216
244
  mode: 'webhook',
217
245
  }) as MaxStatusPatch,
218
246
  );
219
- queue.push(update);
220
- return Promise.resolve();
247
+ const result = await queue.admit(update, maxUpdateDedupeKey(update));
248
+ if (result === 'duplicate') {
249
+ log?.debug?.(`[${account.accountId}] MAX webhook: duplicate ${update.update_type} skipped`);
250
+ }
221
251
  };
222
252
 
223
253
  const target: MaxWebhookTarget = {
@@ -277,6 +307,7 @@ export async function startMaxWebhook(opts: MaxMonitorOptions): Promise<void> {
277
307
  `[${account.accountId}] Webhook update dispatch failed (${update.update_type}): ${String(err)}`,
278
308
  ),
279
309
  onWarn: (message) => log?.warn(message),
310
+ onInfo: (message) => log?.info(message),
280
311
  });
281
312
  stopSubscriptionWatch();
282
313
 
@@ -290,6 +321,32 @@ export async function startMaxWebhook(opts: MaxMonitorOptions): Promise<void> {
290
321
  log?.info(`[${account.accountId}] MAX webhook mode stopped (subscription kept)`);
291
322
  }
292
323
 
324
+ /**
325
+ * The account's durable webhook journal, or undefined for the in-memory queue
326
+ * (webhookQueue.mode "memory", or the state directory is not writable).
327
+ */
328
+ async function openWebhookJournal(opts: MaxMonitorOptions): Promise<MaxUpdateJournal | undefined> {
329
+ const { account, log } = opts;
330
+ if (account.config.webhookQueue?.mode === 'memory') {
331
+ log?.info(
332
+ `[${account.accountId}] MAX webhook queue in memory (webhookQueue.mode "memory"): updates acknowledged but not yet handled are lost on restart`,
333
+ );
334
+ return undefined;
335
+ }
336
+ try {
337
+ return await openMaxUpdateJournal({
338
+ accountId: account.accountId,
339
+ warn: (message) => log?.warn(`[${account.accountId}] ${message}`),
340
+ });
341
+ } catch (err) {
342
+ const code = (err as { code?: unknown })?.code;
343
+ log?.warn(
344
+ `[${account.accountId}] MAX webhook durable queue unavailable (${typeof code === 'string' ? code : String(err)}); using the in-memory queue — updates acknowledged but not yet handled are lost on restart`,
345
+ );
346
+ return undefined;
347
+ }
348
+ }
349
+
293
350
  /** MAX webhook secret: 5–256 chars of [A-Za-z0-9-]. */
294
351
  function generateWebhookSecret(): string {
295
352
  return randomBytes(24).toString('base64url').replace(/_/g, '-');
package/src/webhook.ts CHANGED
@@ -46,6 +46,10 @@ export type MaxWebhookTarget = {
46
46
  config: OpenClawConfig;
47
47
  path: string;
48
48
  secret?: string;
49
+ /**
50
+ * Awaited before MAX gets its 200: must only record/enqueue (never dispatch).
51
+ * A rejection answers 503 so MAX redelivers the update.
52
+ */
49
53
  onUpdate: (update: MaxUpdate) => Promise<void>;
50
54
  log?: (message: string) => void;
51
55
  error?: (message: string) => void;
@@ -116,11 +120,22 @@ export function maxUpdateDedupeKey(update: MaxUpdate): string | undefined {
116
120
  const id =
117
121
  update.callback?.callback_id ??
118
122
  update.message?.body?.mid ??
119
- (typeof update.message_id === 'string' ? update.message_id : undefined);
123
+ // A forward-only message has no body; its link carries the original mid.
124
+ update.message?.link?.message?.mid ??
125
+ (typeof update.message_id === 'string' ? update.message_id : undefined) ??
126
+ // Registry updates (bot_started, bot_added, …) carry no message: their chat
127
+ // and user tell apart two of them in the same millisecond.
128
+ registryUpdateId(update);
120
129
  if (!id && update.timestamp == null) return undefined;
121
130
  return `${update.update_type}:${update.timestamp ?? ''}:${id ?? ''}`;
122
131
  }
123
132
 
133
+ function registryUpdateId(update: MaxUpdate): string | undefined {
134
+ const userId = update.user?.user_id ?? update.user_id;
135
+ const parts = [update.chat_id, userId].filter((part) => part != null);
136
+ return parts.length > 0 ? parts.join('/') : undefined;
137
+ }
138
+
124
139
  /** Returns true when the key was already seen (LRU bounded by DEDUPE_CAPACITY). */
125
140
  function rememberUpdate(seen: Map<string, true>, key: string): boolean {
126
141
  if (seen.has(key)) {
@@ -197,32 +212,38 @@ export async function handleMaxWebhookRequest(
197
212
 
198
213
  const update = raw as MaxUpdate;
199
214
 
200
- // MAX needs 200 within 30 s while an agent turn can take minutes: ack first,
201
- // then hand the update to the account task. Errors are logged, never turned
202
- // into a non-200 (that would only make MAX redeliver and eventually
203
- // unsubscribe the bot). onUpdate must only enqueue: this request's work
204
- // admission is released when the handler returns, so agent/session work
205
- // started from here would fail with GatewayDrainingError.
206
- res.statusCode = 200;
207
- res.setHeader('Content-Type', 'application/json');
208
- res.end(JSON.stringify({ ok: true }));
209
-
210
215
  const dedupeKey = maxUpdateDedupeKey(update);
211
216
  if (dedupeKey && rememberUpdate(matchedTarget.seen, dedupeKey)) {
212
217
  matchedTarget.log?.(
213
218
  `[${matchedTarget.account.accountId}] MAX webhook: duplicate ${dedupeKey} skipped`,
214
219
  );
220
+ res.statusCode = 200;
221
+ res.setHeader('Content-Type', 'application/json');
222
+ res.end(JSON.stringify({ ok: true }));
215
223
  return true;
216
224
  }
217
225
 
218
- void Promise.resolve()
219
- .then(() => matchedTarget.onUpdate(update))
220
- .catch((err: unknown) => {
221
- matchedTarget.error?.(
222
- `[${matchedTarget.account.accountId}] Webhook update processing failed: ${String(err)}`,
223
- );
224
- });
226
+ // MAX needs 200 within 30 s while an agent turn can take minutes: onUpdate
227
+ // only records the update (durable journal) and enqueues it for the account
228
+ // task, then MAX gets its 200. It must not dispatch: this request's work
229
+ // admission is released when the handler returns, so agent/session work
230
+ // started from here would fail with GatewayDrainingError. When the update
231
+ // could not be recorded, 503 makes MAX redeliver it instead of losing it.
232
+ try {
233
+ await matchedTarget.onUpdate(update);
234
+ } catch (err) {
235
+ if (dedupeKey) matchedTarget.seen.delete(dedupeKey);
236
+ matchedTarget.error?.(
237
+ `[${matchedTarget.account.accountId}] MAX webhook: ${update.update_type} not queued, answering 503 for redelivery: ${String(err)}`,
238
+ );
239
+ res.statusCode = 503;
240
+ res.end('Service Unavailable');
241
+ return true;
242
+ }
225
243
 
244
+ res.statusCode = 200;
245
+ res.setHeader('Content-Type', 'application/json');
246
+ res.end(JSON.stringify({ ok: true }));
226
247
  return true;
227
248
  }
228
249
 
@@ -1,38 +0,0 @@
1
- // Fails when src/*.ts local imports form a cycle. Type-only imports count too
2
- // (pass --runtime to ignore them).
3
- import { readdirSync, readFileSync } from "node:fs";
4
- import { join } from "node:path";
5
-
6
- const runtimeOnly = process.argv.includes("--runtime");
7
- const dir = new URL("../src/", import.meta.url).pathname;
8
- const files = readdirSync(dir).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"));
9
- const graph = new Map();
10
- const importRe = /^\s*(import|export)\s+(type\s+)?[^;]*?from\s+["']\.\/([\w.-]+)\.js["']/gms;
11
- for (const file of files) {
12
- const text = readFileSync(join(dir, file), "utf8");
13
- const deps = new Set();
14
- for (const m of text.matchAll(importRe)) {
15
- if (runtimeOnly && m[2]) continue;
16
- deps.add(`${m[3]}.ts`);
17
- }
18
- graph.set(file, [...deps].filter((d) => files.includes(d)));
19
- }
20
- const cycles = [];
21
- const state = new Map();
22
- const stack = [];
23
- const visit = (node) => {
24
- state.set(node, 1);
25
- stack.push(node);
26
- for (const dep of graph.get(node) ?? []) {
27
- if (state.get(dep) === 1) cycles.push([...stack.slice(stack.indexOf(dep)), dep].join(" → "));
28
- else if (!state.has(dep)) visit(dep);
29
- }
30
- stack.pop();
31
- state.set(node, 2);
32
- };
33
- for (const file of files) if (!state.has(file)) visit(file);
34
- if (cycles.length) {
35
- console.error(`import cycles (${runtimeOnly ? "runtime" : "all"} imports):\n${cycles.join("\n")}`);
36
- process.exit(1);
37
- }
38
- console.log(`no import cycles among ${files.length} modules (${runtimeOnly ? "runtime" : "all"} imports)`);