@aspalagin/openclaw-max 0.7.2
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 +21 -0
- package/README.md +424 -0
- package/SKILL.md +108 -0
- package/dist/index.js +17 -0
- package/dist/setup-entry.js +8 -0
- package/dist/src/accounts.js +148 -0
- package/dist/src/actions.js +421 -0
- package/dist/src/api.js +553 -0
- package/dist/src/callbacks.js +111 -0
- package/dist/src/channel-agent-prompt.js +38 -0
- package/dist/src/channel-config.js +103 -0
- package/dist/src/channel-directory.js +84 -0
- package/dist/src/channel-lifecycle.js +219 -0
- package/dist/src/channel-outbound.js +133 -0
- package/dist/src/channel-policy.js +130 -0
- package/dist/src/channel.js +76 -0
- package/dist/src/config-schema.js +133 -0
- package/dist/src/deliver.js +126 -0
- package/dist/src/dispatch.js +186 -0
- package/dist/src/format.js +31 -0
- package/dist/src/inbound-attachments.js +125 -0
- package/dist/src/inbound.js +362 -0
- package/dist/src/media-temp.js +92 -0
- package/dist/src/model-buttons.js +96 -0
- package/dist/src/monitor-types.js +27 -0
- package/dist/src/monitor.js +40 -0
- package/dist/src/onboarding.js +180 -0
- package/dist/src/polling.js +98 -0
- package/dist/src/presentation.js +374 -0
- package/dist/src/runtime.js +36 -0
- package/dist/src/russian-trusted-ca.js +84 -0
- package/dist/src/send.js +612 -0
- package/dist/src/state.js +111 -0
- package/dist/src/sticker-cache.js +26 -0
- package/dist/src/stream-draft.js +102 -0
- package/dist/src/types.js +5 -0
- package/dist/src/webhook-queue.js +215 -0
- package/dist/src/webhook-runner.js +233 -0
- package/dist/src/webhook.js +196 -0
- package/openclaw.plugin.json +263 -0
- package/package.json +100 -0
- package/scripts/check-cycles.mjs +38 -0
- package/scripts/test-api.mjs +169 -0
- package/scripts/test-send.mjs +162 -0
- package/scripts/update-schema.mjs +48 -0
- package/src/accounts.ts +193 -0
- package/src/actions.ts +501 -0
- package/src/api.ts +758 -0
- package/src/callbacks.ts +140 -0
- package/src/channel-agent-prompt.ts +47 -0
- package/src/channel-config.ts +138 -0
- package/src/channel-directory.ts +90 -0
- package/src/channel-lifecycle.ts +256 -0
- package/src/channel-outbound.ts +159 -0
- package/src/channel-policy.ts +185 -0
- package/src/channel.ts +99 -0
- package/src/config-schema.ts +154 -0
- package/src/deliver.ts +168 -0
- package/src/dispatch.ts +224 -0
- package/src/format.ts +33 -0
- package/src/inbound-attachments.ts +152 -0
- package/src/inbound.ts +412 -0
- package/src/media-temp.ts +103 -0
- package/src/model-buttons.ts +150 -0
- package/src/monitor-types.ts +61 -0
- package/src/monitor.ts +49 -0
- package/src/onboarding.ts +215 -0
- package/src/polling.ts +125 -0
- package/src/presentation.ts +452 -0
- package/src/runtime.ts +56 -0
- package/src/russian-trusted-ca.ts +86 -0
- package/src/send.ts +774 -0
- package/src/state.ts +149 -0
- package/src/sticker-cache.ts +28 -0
- package/src/stream-draft.ts +125 -0
- package/src/types.ts +263 -0
- package/src/webhook-queue.ts +248 -0
- package/src/webhook-runner.ts +296 -0
- package/src/webhook.ts +268 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook update queue: the gateway route only enqueues, the account task
|
|
3
|
+
* consumes and dispatches.
|
|
4
|
+
*
|
|
5
|
+
* Why the hop matters: the gateway runs every plugin HTTP handler under a
|
|
6
|
+
* request-scoped root-work admission (AsyncLocalStorage) and releases it as
|
|
7
|
+
* soon as the handler returns. Agent runs and session work started from that
|
|
8
|
+
* async context later see a released admission and fail with
|
|
9
|
+
* `GatewayDrainingError: Gateway is draining` although the gateway is healthy.
|
|
10
|
+
* The account task is started outside any request root, so work it spawns is
|
|
11
|
+
* admitted like the polling loop's. Every dispatch therefore has to start
|
|
12
|
+
* from the consumer (`consume()`), never from `push()`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { MaxUpdate } from './api.js';
|
|
16
|
+
|
|
17
|
+
/** Updates handled at once across chats; one chat is always sequential. */
|
|
18
|
+
export const MAX_WEBHOOK_CHAT_CONCURRENCY = 4;
|
|
19
|
+
/** Accepted-but-unprocessed updates kept per account before new ones are dropped. */
|
|
20
|
+
export const MAX_WEBHOOK_PENDING_LIMIT = 5000;
|
|
21
|
+
/** How long a stopping consumer waits for in-flight dispatches (gateway stop budget is 5 s). */
|
|
22
|
+
export const MAX_WEBHOOK_STOP_GRACE_MS = 3000;
|
|
23
|
+
/** How long a restarted consumer waits for its predecessor's in-flight dispatches. */
|
|
24
|
+
export const MAX_WEBHOOK_HANDOVER_WAIT_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
/** Serialization lane: the chat an update belongs to. */
|
|
27
|
+
export function maxWebhookUpdateLane(update: MaxUpdate): string {
|
|
28
|
+
return String(update.message?.recipient?.chat_id ?? update.chat_id ?? 'global');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Handover = { updates: MaxUpdate[]; settled: Promise<void> };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Updates acked to MAX but not dispatched when an account task stopped
|
|
35
|
+
* (config reload, channel restart). MAX will not redeliver them, so the next
|
|
36
|
+
* task of the same account in this process picks them up.
|
|
37
|
+
*/
|
|
38
|
+
const handovers = new Map<string, Handover>();
|
|
39
|
+
/** Open queues per account: a stopping task hands leftovers to an overlapping successor. */
|
|
40
|
+
const openQueues = new Map<string, Set<(update: MaxUpdate) => void>>();
|
|
41
|
+
|
|
42
|
+
export type MaxWebhookUpdateQueue = {
|
|
43
|
+
/** Called by the route handler (request context): enqueue only, never dispatch. */
|
|
44
|
+
push: (update: MaxUpdate) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Runs in the account task until abortSignal fires: dispatches updates in
|
|
47
|
+
* order per chat, up to `concurrency` chats at once. Resolves after stop.
|
|
48
|
+
*/
|
|
49
|
+
consume: (params: {
|
|
50
|
+
dispatch: (update: MaxUpdate) => Promise<void>;
|
|
51
|
+
abortSignal: AbortSignal;
|
|
52
|
+
onError: (err: unknown, update: MaxUpdate) => void;
|
|
53
|
+
onWarn?: (message: string) => void;
|
|
54
|
+
}) => Promise<void>;
|
|
55
|
+
/** Start failed before consume(): release the queue, keeping taken-over updates. */
|
|
56
|
+
close: () => void;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function createMaxWebhookUpdateQueue(params: {
|
|
60
|
+
accountId: string;
|
|
61
|
+
concurrency?: number;
|
|
62
|
+
pendingLimit?: number;
|
|
63
|
+
stopGraceMs?: number;
|
|
64
|
+
handoverWaitMs?: number;
|
|
65
|
+
}): MaxWebhookUpdateQueue {
|
|
66
|
+
const { accountId } = params;
|
|
67
|
+
const concurrency = Math.max(1, params.concurrency ?? MAX_WEBHOOK_CHAT_CONCURRENCY);
|
|
68
|
+
const pendingLimit = params.pendingLimit ?? MAX_WEBHOOK_PENDING_LIMIT;
|
|
69
|
+
const stopGraceMs = params.stopGraceMs ?? MAX_WEBHOOK_STOP_GRACE_MS;
|
|
70
|
+
const handoverWaitMs = params.handoverWaitMs ?? MAX_WEBHOOK_HANDOVER_WAIT_MS;
|
|
71
|
+
|
|
72
|
+
// Taken over at creation: updates pushed later must queue behind them.
|
|
73
|
+
const previous = handovers.get(accountId);
|
|
74
|
+
handovers.delete(accountId);
|
|
75
|
+
const inbox: MaxUpdate[] = previous ? [...previous.updates] : [];
|
|
76
|
+
let pending = inbox.length;
|
|
77
|
+
let closed = false;
|
|
78
|
+
let wake: (() => void) | undefined;
|
|
79
|
+
let warn: ((message: string) => void) | undefined;
|
|
80
|
+
|
|
81
|
+
const push = (update: MaxUpdate): void => {
|
|
82
|
+
if (closed) {
|
|
83
|
+
// Route still selected this target while the task was stopping.
|
|
84
|
+
const successor = [...siblings].at(-1);
|
|
85
|
+
if (successor) return successor(update);
|
|
86
|
+
const handover = handovers.get(accountId);
|
|
87
|
+
if (handover) handover.updates.push(update);
|
|
88
|
+
else handovers.set(accountId, { updates: [update], settled: Promise.resolve() });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (pending >= pendingLimit) {
|
|
92
|
+
warn?.(
|
|
93
|
+
`[${accountId}] MAX webhook queue full (${pendingLimit}); dropping ${update.update_type}`,
|
|
94
|
+
);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
pending += 1;
|
|
98
|
+
inbox.push(update);
|
|
99
|
+
wake?.();
|
|
100
|
+
};
|
|
101
|
+
const siblings = openQueues.get(accountId) ?? new Set();
|
|
102
|
+
siblings.add(push);
|
|
103
|
+
openQueues.set(accountId, siblings);
|
|
104
|
+
|
|
105
|
+
/** Stop accepting and pass undispatched updates (lanes first, then inbox) on. */
|
|
106
|
+
const close = (fromLanes: MaxUpdate[], settled: Promise<void>): void => {
|
|
107
|
+
if (closed) return;
|
|
108
|
+
closed = true;
|
|
109
|
+
wake = undefined;
|
|
110
|
+
siblings.delete(push);
|
|
111
|
+
if (siblings.size === 0 && openQueues.get(accountId) === siblings) {
|
|
112
|
+
openQueues.delete(accountId);
|
|
113
|
+
}
|
|
114
|
+
const leftover = [...fromLanes, ...inbox];
|
|
115
|
+
inbox.length = 0;
|
|
116
|
+
pending = 0;
|
|
117
|
+
if (leftover.length === 0) return;
|
|
118
|
+
const successor = [...siblings].at(-1);
|
|
119
|
+
if (successor) {
|
|
120
|
+
// A newer task of this account is already consuming: queue there.
|
|
121
|
+
for (const update of leftover) successor(update);
|
|
122
|
+
warn?.(
|
|
123
|
+
`[${accountId}] MAX webhook stopped with ${leftover.length} update(s) not dispatched; moved to the running successor`,
|
|
124
|
+
);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const late = handovers.get(accountId)?.updates ?? [];
|
|
128
|
+
handovers.set(accountId, { updates: [...leftover, ...late], settled });
|
|
129
|
+
warn?.(
|
|
130
|
+
`[${accountId}] MAX webhook stopped with ${leftover.length} update(s) not dispatched; handed over to the next start`,
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const consume: MaxWebhookUpdateQueue['consume'] = async ({
|
|
135
|
+
dispatch,
|
|
136
|
+
abortSignal,
|
|
137
|
+
onError,
|
|
138
|
+
onWarn,
|
|
139
|
+
}) => {
|
|
140
|
+
warn = onWarn;
|
|
141
|
+
const lanes = new Map<string, MaxUpdate[]>();
|
|
142
|
+
const ready: string[] = [];
|
|
143
|
+
const active = new Set<string>();
|
|
144
|
+
const inflight = new Set<Promise<void>>();
|
|
145
|
+
|
|
146
|
+
const route = (update: MaxUpdate): void => {
|
|
147
|
+
const lane = maxWebhookUpdateLane(update);
|
|
148
|
+
const queued = lanes.get(lane);
|
|
149
|
+
if (queued) queued.push(update);
|
|
150
|
+
else lanes.set(lane, [update]);
|
|
151
|
+
if (!active.has(lane) && !ready.includes(lane)) ready.push(lane);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const runLane = async (lane: string): Promise<void> => {
|
|
155
|
+
try {
|
|
156
|
+
for (;;) {
|
|
157
|
+
if (abortSignal.aborted) return;
|
|
158
|
+
const update = lanes.get(lane)?.shift();
|
|
159
|
+
if (!update) return;
|
|
160
|
+
pending -= 1;
|
|
161
|
+
try {
|
|
162
|
+
await dispatch(update);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
onError(err, update);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
active.delete(lane);
|
|
169
|
+
if (lanes.get(lane)?.length === 0) lanes.delete(lane);
|
|
170
|
+
else if (lanes.has(lane) && !ready.includes(lane)) ready.push(lane);
|
|
171
|
+
pump();
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Called only from this consumer's async chain (loop or runLane), so every
|
|
176
|
+
// dispatch inherits the account task's context.
|
|
177
|
+
const pump = (): void => {
|
|
178
|
+
if (abortSignal.aborted) return;
|
|
179
|
+
while (active.size < concurrency && ready.length > 0) {
|
|
180
|
+
const lane = ready.shift() as string;
|
|
181
|
+
active.add(lane);
|
|
182
|
+
const run = runLane(lane);
|
|
183
|
+
inflight.add(run);
|
|
184
|
+
void run.finally(() => inflight.delete(run));
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const nextWake = (): Promise<void> =>
|
|
189
|
+
new Promise<void>((resolve) => {
|
|
190
|
+
wake = resolve;
|
|
191
|
+
if (inbox.length > 0 || abortSignal.aborted) resolve();
|
|
192
|
+
});
|
|
193
|
+
const onAbort = (): void => wake?.();
|
|
194
|
+
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
if (previous && !abortSignal.aborted) {
|
|
198
|
+
// Keep per-chat order across a restart: the stopped task may still be
|
|
199
|
+
// finishing an update of a chat whose next updates we now hold.
|
|
200
|
+
await waitAtMost(previous.settled, handoverWaitMs, abortSignal);
|
|
201
|
+
}
|
|
202
|
+
while (!abortSignal.aborted) {
|
|
203
|
+
while (inbox.length > 0) route(inbox.shift() as MaxUpdate);
|
|
204
|
+
pump();
|
|
205
|
+
await nextWake();
|
|
206
|
+
wake = undefined;
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
abortSignal.removeEventListener('abort', onAbort);
|
|
210
|
+
const leftover: MaxUpdate[] = [];
|
|
211
|
+
for (const queued of lanes.values()) leftover.push(...queued);
|
|
212
|
+
lanes.clear();
|
|
213
|
+
const settled = Promise.allSettled([...inflight]).then(() => undefined);
|
|
214
|
+
close(leftover, settled);
|
|
215
|
+
await waitAtMost(settled, stopGraceMs);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return { push, consume, close: () => close([], Promise.resolve()) };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Resolves when `promise` settles, `ms` elapse or `signal` aborts — whichever is first. */
|
|
223
|
+
async function waitAtMost(promise: Promise<void>, ms: number, signal?: AbortSignal): Promise<void> {
|
|
224
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
225
|
+
let onAbort: (() => void) | undefined;
|
|
226
|
+
try {
|
|
227
|
+
await Promise.race([
|
|
228
|
+
promise,
|
|
229
|
+
new Promise<void>((resolve) => {
|
|
230
|
+
timer = setTimeout(resolve, ms);
|
|
231
|
+
timer.unref?.();
|
|
232
|
+
if (signal) {
|
|
233
|
+
onAbort = resolve;
|
|
234
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
235
|
+
}
|
|
236
|
+
}),
|
|
237
|
+
]);
|
|
238
|
+
} finally {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** @internal test helper: forget handed-over updates. */
|
|
245
|
+
export function resetMaxWebhookHandoversForTest(): void {
|
|
246
|
+
handovers.clear();
|
|
247
|
+
openQueues.clear();
|
|
248
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook transport: secret resolution, MAX subscription lifecycle (sync on
|
|
3
|
+
* start, periodic re-check), the gateway route/target registration and the
|
|
4
|
+
* account-task consumer of the ack-first per-chat update queue.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
import type { ChannelLogSink } from 'openclaw/plugin-sdk/channel-contract';
|
|
10
|
+
import { channelReadyPatch } from 'openclaw/plugin-sdk/gateway-runtime';
|
|
11
|
+
|
|
12
|
+
import { readSecretFile, type ResolvedMaxAccount } from './accounts.js';
|
|
13
|
+
import type { MaxApi, MaxSubscription, MaxUpdate } from './api.js';
|
|
14
|
+
import { dispatchUpdate } from './dispatch.js';
|
|
15
|
+
import {
|
|
16
|
+
MAX_SUBSCRIBED_UPDATE_TYPES,
|
|
17
|
+
type MaxMonitorOptions,
|
|
18
|
+
type MaxStatusPatch,
|
|
19
|
+
} from './monitor-types.js';
|
|
20
|
+
import type { MaxStateStore } from './state.js';
|
|
21
|
+
import {
|
|
22
|
+
type MaxWebhookTarget,
|
|
23
|
+
registerMaxWebhookRoute,
|
|
24
|
+
registerMaxWebhookTarget,
|
|
25
|
+
resolveMaxWebhookPath,
|
|
26
|
+
subscribeMaxWebhook,
|
|
27
|
+
} from './webhook.js';
|
|
28
|
+
import { createMaxWebhookUpdateQueue } from './webhook-queue.js';
|
|
29
|
+
|
|
30
|
+
/** MAX secret format (SubscriptionRequestBody.secret): 5–256 of [A-Za-z0-9_-]. */
|
|
31
|
+
const MAX_WEBHOOK_SECRET_PATTERN = /^[\w-]{5,256}$/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Webhook secret: `webhookSecret`, else `webhookSecretFile`, else one generated
|
|
35
|
+
* once and kept in the account state file so restarts re-subscribe with the
|
|
36
|
+
* same value (a fresh secret per start would reject MAX retries of updates
|
|
37
|
+
* sent before the restart).
|
|
38
|
+
* @internal exported for testing.
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveMaxWebhookSecret(
|
|
41
|
+
account: ResolvedMaxAccount,
|
|
42
|
+
state?: MaxStateStore,
|
|
43
|
+
): Promise<string> {
|
|
44
|
+
const validate = (secret: string, source: string): string => {
|
|
45
|
+
if (!MAX_WEBHOOK_SECRET_PATTERN.test(secret)) {
|
|
46
|
+
throw new Error(`MAX ${source} must be 5–256 characters of A-Z, a-z, 0-9, _ and -`);
|
|
47
|
+
}
|
|
48
|
+
return secret;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const configured = account.config.webhookSecret?.trim();
|
|
52
|
+
if (configured) return validate(configured, 'webhookSecret');
|
|
53
|
+
|
|
54
|
+
const secretFile = account.config.webhookSecretFile?.trim();
|
|
55
|
+
if (secretFile) {
|
|
56
|
+
const fromFile = readSecretFile(secretFile);
|
|
57
|
+
if (!fromFile)
|
|
58
|
+
throw new Error(
|
|
59
|
+
`MAX webhookSecretFile ${secretFile} is missing, empty or not a regular file`,
|
|
60
|
+
);
|
|
61
|
+
return validate(fromFile, 'webhookSecretFile');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const stored = state?.webhookSecret;
|
|
65
|
+
if (stored && MAX_WEBHOOK_SECRET_PATTERN.test(stored)) return stored;
|
|
66
|
+
|
|
67
|
+
const generated = generateWebhookSecret();
|
|
68
|
+
if (state) {
|
|
69
|
+
state.setWebhookSecret(generated);
|
|
70
|
+
await state.flush();
|
|
71
|
+
}
|
|
72
|
+
return generated;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Make the MAX side point at exactly one URL: drop this bot's subscriptions to
|
|
77
|
+
* other URLs, then (re)subscribe ours. POST is repeated on every start so the
|
|
78
|
+
* secret and update_types always match the running config.
|
|
79
|
+
* @internal exported for testing.
|
|
80
|
+
*/
|
|
81
|
+
export async function syncMaxWebhookSubscription(params: {
|
|
82
|
+
api: MaxApi;
|
|
83
|
+
accountId: string;
|
|
84
|
+
webhookUrl: string;
|
|
85
|
+
secret: string;
|
|
86
|
+
log?: ChannelLogSink;
|
|
87
|
+
}): Promise<void> {
|
|
88
|
+
const { api, accountId, webhookUrl, secret, log } = params;
|
|
89
|
+
let existing: MaxSubscription[] = [];
|
|
90
|
+
try {
|
|
91
|
+
existing = (await api.getSubscriptions()).subscriptions ?? [];
|
|
92
|
+
} catch (err) {
|
|
93
|
+
log?.warn(`[${accountId}] MAX subscriptions check failed, subscribing anyway: ${String(err)}`);
|
|
94
|
+
}
|
|
95
|
+
for (const subscription of existing) {
|
|
96
|
+
if (subscription.url === webhookUrl) continue;
|
|
97
|
+
log?.warn(
|
|
98
|
+
`[${accountId}] MAX webhook: removing subscription to another URL ${subscription.url}`,
|
|
99
|
+
);
|
|
100
|
+
await api.unsubscribe(subscription.url);
|
|
101
|
+
}
|
|
102
|
+
await subscribeMaxWebhook({
|
|
103
|
+
api,
|
|
104
|
+
webhookUrl,
|
|
105
|
+
secret,
|
|
106
|
+
updateTypes: MAX_SUBSCRIBED_UPDATE_TYPES,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** How often webhook mode re-checks that MAX still holds our subscription. */
|
|
111
|
+
export const MAX_SUBSCRIPTION_CHECK_INTERVAL_MS = 12 * 60 * 1000;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* MAX drops a subscription after 8 hours of failed deliveries (tunnel or
|
|
115
|
+
* gateway outage), and then nothing arrives until the next restart. Re-check
|
|
116
|
+
* periodically and re-subscribe when our URL is gone. Network/API errors on
|
|
117
|
+
* the check are only logged; the next tick tries again.
|
|
118
|
+
* Returns a stop function; the timer is also cleared when abortSignal fires.
|
|
119
|
+
* @internal exported for testing.
|
|
120
|
+
*/
|
|
121
|
+
export function startMaxSubscriptionWatch(params: {
|
|
122
|
+
api: MaxApi;
|
|
123
|
+
accountId: string;
|
|
124
|
+
webhookUrl: string;
|
|
125
|
+
secret: string;
|
|
126
|
+
abortSignal: AbortSignal;
|
|
127
|
+
log?: ChannelLogSink;
|
|
128
|
+
intervalMs?: number;
|
|
129
|
+
}): () => void {
|
|
130
|
+
const { api, accountId, webhookUrl, secret, abortSignal, log } = params;
|
|
131
|
+
let running = false;
|
|
132
|
+
|
|
133
|
+
const check = async (): Promise<void> => {
|
|
134
|
+
if (running || abortSignal.aborted) return;
|
|
135
|
+
running = true;
|
|
136
|
+
try {
|
|
137
|
+
let subscriptions: MaxSubscription[];
|
|
138
|
+
try {
|
|
139
|
+
subscriptions = (await api.getSubscriptions()).subscriptions ?? [];
|
|
140
|
+
} catch (err) {
|
|
141
|
+
log?.warn(`[${accountId}] MAX subscription check failed: ${String(err)}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (abortSignal.aborted || subscriptions.some((s) => s.url === webhookUrl)) return;
|
|
145
|
+
log?.warn(
|
|
146
|
+
`[${accountId}] MAX webhook subscription to ${webhookUrl} is gone (MAX unsubscribes after 8 h of failed deliveries); re-subscribing`,
|
|
147
|
+
);
|
|
148
|
+
try {
|
|
149
|
+
await subscribeMaxWebhook({
|
|
150
|
+
api,
|
|
151
|
+
webhookUrl,
|
|
152
|
+
secret,
|
|
153
|
+
updateTypes: MAX_SUBSCRIBED_UPDATE_TYPES,
|
|
154
|
+
});
|
|
155
|
+
log?.info(`[${accountId}] MAX webhook re-subscribed: ${webhookUrl}`);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
log?.error(`[${accountId}] MAX webhook re-subscribe failed: ${String(err)}`);
|
|
158
|
+
}
|
|
159
|
+
} finally {
|
|
160
|
+
running = false;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const timer = setInterval(
|
|
165
|
+
() => void check(),
|
|
166
|
+
params.intervalMs ?? MAX_SUBSCRIPTION_CHECK_INTERVAL_MS,
|
|
167
|
+
);
|
|
168
|
+
timer.unref?.();
|
|
169
|
+
const stop = (): void => {
|
|
170
|
+
clearInterval(timer);
|
|
171
|
+
abortSignal.removeEventListener('abort', stop);
|
|
172
|
+
};
|
|
173
|
+
abortSignal.addEventListener('abort', stop, { once: true });
|
|
174
|
+
return stop;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function startMaxWebhook(opts: MaxMonitorOptions): Promise<void> {
|
|
178
|
+
const { api, account, config, abortSignal, log, statusSink } = opts;
|
|
179
|
+
|
|
180
|
+
const webhookUrl = account.config.webhookUrl?.trim();
|
|
181
|
+
if (!webhookUrl) {
|
|
182
|
+
throw new Error(`MAX transport "webhook" requires webhookUrl (account ${account.accountId})`);
|
|
183
|
+
}
|
|
184
|
+
if (!webhookUrl.startsWith('https://')) {
|
|
185
|
+
log?.warn(
|
|
186
|
+
`[${account.accountId}] MAX accepts only HTTPS webhook URLs on port 443; got ${webhookUrl}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const webhookPath = resolveMaxWebhookPath(account.config.webhookPath, webhookUrl);
|
|
191
|
+
const webhookSecret = await resolveMaxWebhookSecret(account, opts.state);
|
|
192
|
+
|
|
193
|
+
log?.info(`[${account.accountId}] MAX webhook mode: ${webhookUrl} (path: ${webhookPath})`);
|
|
194
|
+
|
|
195
|
+
// Like Telegram webhook mode: no transport-activity timestamp, so the health
|
|
196
|
+
// policy never flags a quiet (but healthy) webhook as a stale socket.
|
|
197
|
+
statusSink?.({
|
|
198
|
+
mode: 'webhook',
|
|
199
|
+
connected: false,
|
|
200
|
+
lastConnectedAt: null,
|
|
201
|
+
lastEventAt: null,
|
|
202
|
+
lastTransportActivityAt: null,
|
|
203
|
+
} as MaxStatusPatch);
|
|
204
|
+
|
|
205
|
+
// 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
|
+
const at = Date.now();
|
|
212
|
+
statusSink?.(
|
|
213
|
+
channelReadyPatch({
|
|
214
|
+
lastConnectedAt: at,
|
|
215
|
+
lastEventAt: at,
|
|
216
|
+
mode: 'webhook',
|
|
217
|
+
}) as MaxStatusPatch,
|
|
218
|
+
);
|
|
219
|
+
queue.push(update);
|
|
220
|
+
return Promise.resolve();
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const target: MaxWebhookTarget = {
|
|
224
|
+
account,
|
|
225
|
+
config,
|
|
226
|
+
path: webhookPath,
|
|
227
|
+
secret: webhookSecret,
|
|
228
|
+
onUpdate,
|
|
229
|
+
log: (msg) => log?.debug?.(msg),
|
|
230
|
+
error: (msg) => log?.error(msg),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const unregisterTarget = registerMaxWebhookTarget(target);
|
|
234
|
+
let unregisterRoute: () => void = () => {};
|
|
235
|
+
try {
|
|
236
|
+
// Route first: MAX may deliver the moment the subscription exists.
|
|
237
|
+
unregisterRoute = registerMaxWebhookRoute({
|
|
238
|
+
path: webhookPath,
|
|
239
|
+
accountId: account.accountId,
|
|
240
|
+
log: (msg) => log?.warn(`[${account.accountId}] ${msg}`),
|
|
241
|
+
register: opts.registerWebhookRoute,
|
|
242
|
+
});
|
|
243
|
+
await syncMaxWebhookSubscription({
|
|
244
|
+
api,
|
|
245
|
+
accountId: account.accountId,
|
|
246
|
+
webhookUrl,
|
|
247
|
+
secret: webhookSecret,
|
|
248
|
+
log,
|
|
249
|
+
});
|
|
250
|
+
log?.info(`[${account.accountId}] MAX webhook subscribed: ${webhookUrl}`);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
log?.error(`[${account.accountId}] MAX webhook start failed: ${String(err)}`);
|
|
253
|
+
unregisterRoute();
|
|
254
|
+
unregisterTarget();
|
|
255
|
+
queue.close();
|
|
256
|
+
statusSink?.({ mode: 'webhook', connected: false, lastError: String(err) } as MaxStatusPatch);
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
statusSink?.(channelReadyPatch({ mode: 'webhook' }) as MaxStatusPatch);
|
|
261
|
+
|
|
262
|
+
const stopSubscriptionWatch = startMaxSubscriptionWatch({
|
|
263
|
+
api,
|
|
264
|
+
accountId: account.accountId,
|
|
265
|
+
webhookUrl,
|
|
266
|
+
secret: webhookSecret,
|
|
267
|
+
abortSignal,
|
|
268
|
+
log,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Runs until abort; every dispatch starts here, under the account task.
|
|
272
|
+
await queue.consume({
|
|
273
|
+
dispatch: (update) => dispatchUpdate(update, opts),
|
|
274
|
+
abortSignal,
|
|
275
|
+
onError: (err, update) =>
|
|
276
|
+
log?.error(
|
|
277
|
+
`[${account.accountId}] Webhook update dispatch failed (${update.update_type}): ${String(err)}`,
|
|
278
|
+
),
|
|
279
|
+
onWarn: (message) => log?.warn(message),
|
|
280
|
+
});
|
|
281
|
+
stopSubscriptionWatch();
|
|
282
|
+
|
|
283
|
+
// The subscription is kept on purpose: a restart or config reload must not
|
|
284
|
+
// lose updates, and MAX retries undelivered ones while the route is back.
|
|
285
|
+
// To leave webhook mode, switch transport to "polling" (the polling start
|
|
286
|
+
// removes it) or call DELETE /subscriptions?url=… by hand.
|
|
287
|
+
unregisterRoute();
|
|
288
|
+
unregisterTarget();
|
|
289
|
+
statusSink?.({ mode: 'webhook', connected: false } as MaxStatusPatch);
|
|
290
|
+
log?.info(`[${account.accountId}] MAX webhook mode stopped (subscription kept)`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** MAX webhook secret: 5–256 chars of [A-Za-z0-9-]. */
|
|
294
|
+
function generateWebhookSecret(): string {
|
|
295
|
+
return randomBytes(24).toString('base64url').replace(/_/g, '-');
|
|
296
|
+
}
|