@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.
- package/LICENSE +2 -0
- package/README.md +412 -344
- package/README_EN.md +492 -0
- package/SKILL.md +45 -37
- package/assets/icon.png +0 -0
- package/dist/secret-contract-api.js +6 -0
- package/dist/src/access-policy.js +123 -0
- package/dist/src/accounts.js +170 -23
- package/dist/src/action-scope.js +213 -0
- package/dist/src/actions.js +159 -31
- package/dist/src/api.js +99 -83
- package/dist/src/callbacks.js +27 -7
- package/dist/src/channel-agent-prompt.js +8 -19
- package/dist/src/channel-config.js +25 -3
- package/dist/src/channel-lifecycle.js +28 -5
- package/dist/src/channel-outbound.js +43 -9
- package/dist/src/channel-policy.js +19 -6
- package/dist/src/channel.js +16 -0
- package/dist/src/command-menu.js +199 -0
- package/dist/src/config-schema.js +106 -10
- package/dist/src/deliver.js +144 -48
- package/dist/src/dispatch.js +12 -11
- package/dist/src/gateway-drain.js +40 -0
- package/dist/src/group-mention.js +129 -0
- package/dist/src/inbound-attachments.js +162 -20
- package/dist/src/inbound.js +388 -134
- package/dist/src/media-temp.js +31 -20
- package/dist/src/network.js +216 -0
- package/dist/src/onboarding.js +4 -2
- package/dist/src/polling.js +180 -12
- package/dist/src/presentation.js +11 -0
- package/dist/src/progress-draft.js +168 -0
- package/dist/src/runtime.js +15 -0
- package/dist/src/secret-contract.js +149 -0
- package/dist/src/send.js +250 -46
- package/dist/src/stream-draft.js +83 -41
- package/dist/src/turn-adoption.js +24 -0
- package/dist/src/typing.js +55 -0
- package/dist/src/update-journal.js +267 -0
- package/dist/src/webhook-queue.js +151 -26
- package/dist/src/webhook-runner.js +61 -9
- package/dist/src/webhook.js +34 -15
- package/openclaw.plugin.json +983 -8
- package/package.json +17 -4
- package/src/access-policy.ts +154 -0
- package/src/accounts.ts +260 -25
- package/src/action-scope.ts +274 -0
- package/src/actions.ts +188 -35
- package/src/api.ts +158 -101
- package/src/callbacks.ts +26 -7
- package/src/channel-agent-prompt.ts +8 -19
- package/src/channel-config.ts +32 -3
- package/src/channel-lifecycle.ts +33 -6
- package/src/channel-outbound.ts +50 -7
- package/src/channel-policy.ts +27 -7
- package/src/channel.ts +16 -0
- package/src/command-menu.ts +277 -0
- package/src/config-schema.ts +111 -10
- package/src/deliver.ts +174 -55
- package/src/dispatch.ts +12 -11
- package/src/gateway-drain.ts +45 -0
- package/src/group-mention.ts +191 -0
- package/src/inbound-attachments.ts +173 -22
- package/src/inbound.ts +443 -130
- package/src/media-temp.ts +35 -23
- package/src/network.ts +233 -0
- package/src/onboarding.ts +4 -2
- package/src/polling.ts +221 -15
- package/src/presentation.ts +14 -1
- package/src/progress-draft.ts +226 -0
- package/src/runtime.ts +23 -0
- package/src/secret-contract.ts +183 -0
- package/src/send.ts +346 -59
- package/src/stream-draft.ts +102 -41
- package/src/turn-adoption.ts +33 -0
- package/src/types.ts +9 -3
- package/src/typing.ts +77 -0
- package/src/update-journal.ts +302 -0
- package/src/webhook-queue.ts +205 -33
- package/src/webhook-runner.ts +65 -8
- package/src/webhook.ts +39 -18
- package/scripts/check-cycles.mjs +0 -38
- package/scripts/test-api.mjs +0 -169
- package/scripts/test-send.mjs +0 -162
- package/scripts/update-schema.mjs +0 -48
package/src/webhook-queue.ts
CHANGED
|
@@ -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
|
-
|
|
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<(
|
|
93
|
+
const openQueues = new Map<string, Set<(item: QueuedUpdate) => void>>();
|
|
41
94
|
|
|
42
95
|
export type MaxWebhookUpdateQueue = {
|
|
43
|
-
/**
|
|
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:
|
|
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
|
|
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(
|
|
163
|
+
if (successor) return successor(item);
|
|
86
164
|
const handover = handovers.get(accountId);
|
|
87
|
-
if (handover) handover.updates.push(
|
|
88
|
-
else handovers.set(accountId, { updates: [
|
|
165
|
+
if (handover) handover.updates.push(item);
|
|
166
|
+
else handovers.set(accountId, { updates: [item], settled: Promise.resolve() });
|
|
89
167
|
return;
|
|
90
168
|
}
|
|
91
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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:
|
|
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(
|
|
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
|
|
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,
|
|
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 = (
|
|
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(
|
|
150
|
-
else lanes.set(lane, [
|
|
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
|
|
159
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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. */
|
package/src/webhook-runner.ts
CHANGED
|
@@ -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
|
|
207
|
-
// (see webhook-queue.ts: dispatching from the request context
|
|
208
|
-
// GatewayDrainingError once the request's work admission is
|
|
209
|
-
|
|
210
|
-
const
|
|
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.
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
package/scripts/check-cycles.mjs
DELETED
|
@@ -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)`);
|