@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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/SKILL.md +108 -0
  4. package/dist/index.js +17 -0
  5. package/dist/setup-entry.js +8 -0
  6. package/dist/src/accounts.js +148 -0
  7. package/dist/src/actions.js +421 -0
  8. package/dist/src/api.js +553 -0
  9. package/dist/src/callbacks.js +111 -0
  10. package/dist/src/channel-agent-prompt.js +38 -0
  11. package/dist/src/channel-config.js +103 -0
  12. package/dist/src/channel-directory.js +84 -0
  13. package/dist/src/channel-lifecycle.js +219 -0
  14. package/dist/src/channel-outbound.js +133 -0
  15. package/dist/src/channel-policy.js +130 -0
  16. package/dist/src/channel.js +76 -0
  17. package/dist/src/config-schema.js +133 -0
  18. package/dist/src/deliver.js +126 -0
  19. package/dist/src/dispatch.js +186 -0
  20. package/dist/src/format.js +31 -0
  21. package/dist/src/inbound-attachments.js +125 -0
  22. package/dist/src/inbound.js +362 -0
  23. package/dist/src/media-temp.js +92 -0
  24. package/dist/src/model-buttons.js +96 -0
  25. package/dist/src/monitor-types.js +27 -0
  26. package/dist/src/monitor.js +40 -0
  27. package/dist/src/onboarding.js +180 -0
  28. package/dist/src/polling.js +98 -0
  29. package/dist/src/presentation.js +374 -0
  30. package/dist/src/runtime.js +36 -0
  31. package/dist/src/russian-trusted-ca.js +84 -0
  32. package/dist/src/send.js +612 -0
  33. package/dist/src/state.js +111 -0
  34. package/dist/src/sticker-cache.js +26 -0
  35. package/dist/src/stream-draft.js +102 -0
  36. package/dist/src/types.js +5 -0
  37. package/dist/src/webhook-queue.js +215 -0
  38. package/dist/src/webhook-runner.js +233 -0
  39. package/dist/src/webhook.js +196 -0
  40. package/openclaw.plugin.json +263 -0
  41. package/package.json +100 -0
  42. package/scripts/check-cycles.mjs +38 -0
  43. package/scripts/test-api.mjs +169 -0
  44. package/scripts/test-send.mjs +162 -0
  45. package/scripts/update-schema.mjs +48 -0
  46. package/src/accounts.ts +193 -0
  47. package/src/actions.ts +501 -0
  48. package/src/api.ts +758 -0
  49. package/src/callbacks.ts +140 -0
  50. package/src/channel-agent-prompt.ts +47 -0
  51. package/src/channel-config.ts +138 -0
  52. package/src/channel-directory.ts +90 -0
  53. package/src/channel-lifecycle.ts +256 -0
  54. package/src/channel-outbound.ts +159 -0
  55. package/src/channel-policy.ts +185 -0
  56. package/src/channel.ts +99 -0
  57. package/src/config-schema.ts +154 -0
  58. package/src/deliver.ts +168 -0
  59. package/src/dispatch.ts +224 -0
  60. package/src/format.ts +33 -0
  61. package/src/inbound-attachments.ts +152 -0
  62. package/src/inbound.ts +412 -0
  63. package/src/media-temp.ts +103 -0
  64. package/src/model-buttons.ts +150 -0
  65. package/src/monitor-types.ts +61 -0
  66. package/src/monitor.ts +49 -0
  67. package/src/onboarding.ts +215 -0
  68. package/src/polling.ts +125 -0
  69. package/src/presentation.ts +452 -0
  70. package/src/runtime.ts +56 -0
  71. package/src/russian-trusted-ca.ts +86 -0
  72. package/src/send.ts +774 -0
  73. package/src/state.ts +149 -0
  74. package/src/sticker-cache.ts +28 -0
  75. package/src/stream-draft.ts +125 -0
  76. package/src/types.ts +263 -0
  77. package/src/webhook-queue.ts +248 -0
  78. package/src/webhook-runner.ts +296 -0
  79. package/src/webhook.ts +268 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Persistent per-account MAX state: long-polling marker, chat registry and the
3
+ * auto-generated webhook secret.
4
+ *
5
+ * The marker survives gateway restarts so updates are neither replayed nor
6
+ * lost past the server-side retention window. The chat registry replaces the
7
+ * deprecated GET /chats (June 2026): chats are collected from bot_added /
8
+ * bot_started / chat_title_changed updates and passively from group messages.
9
+ */
10
+ import * as fs from 'node:fs/promises';
11
+ import * as path from 'node:path';
12
+ import { readJsonFileWithFallback, writeJsonFileAtomically } from 'openclaw/plugin-sdk/json-store';
13
+ import { resolveStateDir } from 'openclaw/plugin-sdk/state-paths';
14
+ export function resolveMaxStatePath(accountId) {
15
+ const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, '_');
16
+ return path.join(resolveStateDir(), 'max', `state-${safeId}.json`);
17
+ }
18
+ export async function loadMaxAccountState(accountId) {
19
+ const { value } = await readJsonFileWithFallback(resolveMaxStatePath(accountId), {});
20
+ return value && typeof value === 'object' ? value : {};
21
+ }
22
+ export async function saveMaxAccountState(accountId, state) {
23
+ const filePath = resolveMaxStatePath(accountId);
24
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
25
+ await writeJsonFileAtomically(filePath, state);
26
+ }
27
+ /**
28
+ * In-memory handle over the persisted state with coalesced writes.
29
+ * Mutations mark the state dirty; flushes are serialized and best-effort.
30
+ */
31
+ export class MaxStateStore {
32
+ accountId;
33
+ onError;
34
+ state = {};
35
+ dirty = false;
36
+ flushing = Promise.resolve();
37
+ loaded = false;
38
+ constructor(accountId, onError) {
39
+ this.accountId = accountId;
40
+ this.onError = onError;
41
+ }
42
+ async load() {
43
+ if (!this.loaded) {
44
+ this.state = await loadMaxAccountState(this.accountId);
45
+ this.loaded = true;
46
+ }
47
+ return this.state;
48
+ }
49
+ get marker() {
50
+ return this.state.marker;
51
+ }
52
+ setMarker(marker) {
53
+ if (this.state.marker === marker)
54
+ return;
55
+ this.state.marker = marker;
56
+ this.scheduleFlush();
57
+ }
58
+ get webhookSecret() {
59
+ return this.state.webhookSecret;
60
+ }
61
+ setWebhookSecret(secret) {
62
+ if (this.state.webhookSecret === secret)
63
+ return;
64
+ this.state.webhookSecret = secret;
65
+ this.scheduleFlush();
66
+ }
67
+ get chats() {
68
+ if (!this.state.chats)
69
+ this.state.chats = {};
70
+ return this.state.chats;
71
+ }
72
+ upsertChat(chatId, patch) {
73
+ const key = String(chatId);
74
+ const existing = this.chats[key];
75
+ const next = { ...existing, ...patch, chatId };
76
+ // A re-appearing chat is no longer removed/stopped unless the patch says so
77
+ if (patch.removedAt === undefined &&
78
+ existing?.removedAt !== undefined &&
79
+ patch.addedAt !== undefined) {
80
+ delete next.removedAt;
81
+ }
82
+ this.chats[key] = next;
83
+ this.scheduleFlush();
84
+ }
85
+ hasChat(chatId) {
86
+ return String(chatId) in this.chats;
87
+ }
88
+ /** True only if the chat is known AND not marked removed. */
89
+ hasActiveChat(chatId) {
90
+ const entry = this.chats[String(chatId)];
91
+ return Boolean(entry) && entry.removedAt == null;
92
+ }
93
+ scheduleFlush() {
94
+ this.dirty = true;
95
+ this.flushing = this.flushing.then(async () => {
96
+ if (!this.dirty)
97
+ return;
98
+ this.dirty = false;
99
+ try {
100
+ await saveMaxAccountState(this.accountId, this.state);
101
+ }
102
+ catch (err) {
103
+ this.onError?.(err);
104
+ }
105
+ });
106
+ }
107
+ /** Wait for pending writes (used on shutdown/tests). */
108
+ async flush() {
109
+ await this.flushing;
110
+ }
111
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Simple in-memory sticker code cache.
3
+ * Stores the last received sticker code per chat, so the agent can
4
+ * send it back without explicitly passing the code.
5
+ */
6
+ const lastStickerByChat = new Map();
7
+ const lastStickerGlobal = { code: null };
8
+ /**
9
+ * Store a sticker code received from a chat.
10
+ */
11
+ export function rememberStickerCode(chatId, code) {
12
+ const key = String(chatId);
13
+ lastStickerByChat.set(key, code);
14
+ lastStickerGlobal.code = code;
15
+ }
16
+ /**
17
+ * Get the last sticker code for a chat, or the last global one.
18
+ */
19
+ export function getLastStickerCode(chatId) {
20
+ if (chatId != null) {
21
+ const code = lastStickerByChat.get(String(chatId));
22
+ if (code)
23
+ return code;
24
+ }
25
+ return lastStickerGlobal.code;
26
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Edit streaming (streamMode "partial"): the first partial reply is sent as a
3
+ * message, later partials edit it (throttled), and the final answer replaces
4
+ * the draft together with its inline keyboard.
5
+ */
6
+ import { editMaxMessage, sendMaxMessage } from './send.js';
7
+ const DRAFT_THROTTLE_MS = 1200;
8
+ const DRAFT_MAX_CHARS = 4000;
9
+ const DRAFT_MIN_CHARS = 30; // Don't send until we have enough text
10
+ /** Draft stream for edit-streaming (like Telegram's partial reply approach). */
11
+ export function createMaxDraftStream(params) {
12
+ const { account, chatId, log, statusSink } = params;
13
+ let draftMid = null;
14
+ let draftLastText = '';
15
+ let draftLastEditAt = 0;
16
+ let draftTimer = null;
17
+ let draftStopped = false;
18
+ const draftUpdate = async (text) => {
19
+ if (draftStopped || !text)
20
+ return;
21
+ const trimmed = text.trimEnd();
22
+ if (!trimmed || trimmed === draftLastText)
23
+ return;
24
+ if (trimmed.length > DRAFT_MAX_CHARS) {
25
+ draftStopped = true;
26
+ return;
27
+ }
28
+ if (!draftMid && trimmed.length < DRAFT_MIN_CHARS)
29
+ return; // wait for more text
30
+ // Clear pending timer
31
+ if (draftTimer) {
32
+ clearTimeout(draftTimer);
33
+ draftTimer = null;
34
+ }
35
+ const now = Date.now();
36
+ const elapsed = now - draftLastEditAt;
37
+ if (elapsed < DRAFT_THROTTLE_MS) {
38
+ // Schedule a deferred update
39
+ draftTimer = setTimeout(() => {
40
+ draftUpdate(text);
41
+ }, DRAFT_THROTTLE_MS - elapsed);
42
+ return;
43
+ }
44
+ draftLastText = trimmed;
45
+ draftLastEditAt = now;
46
+ try {
47
+ if (!draftMid) {
48
+ // First chunk — send new message
49
+ const res = await sendMaxMessage(chatId, trimmed, {
50
+ token: account.token,
51
+ replyToMessageId: params.replyToId,
52
+ format: 'markdown',
53
+ });
54
+ draftMid = res.messageId || null;
55
+ statusSink?.({ lastOutboundAt: Date.now() });
56
+ }
57
+ else {
58
+ // Edit existing message
59
+ await editMaxMessage(draftMid, trimmed, {
60
+ token: account.token,
61
+ format: 'markdown',
62
+ });
63
+ }
64
+ }
65
+ catch (err) {
66
+ draftStopped = true;
67
+ log?.debug?.(`[${account.accountId}] MAX draft stream failed: ${String(err)}`);
68
+ }
69
+ };
70
+ return {
71
+ get messageId() {
72
+ return draftMid;
73
+ },
74
+ update: draftUpdate,
75
+ finalize: async (finalText, buttons) => {
76
+ // Final delivery replaces the draft message with final text. The
77
+ // keyboard (presentation buttons) goes onto the same edit, otherwise
78
+ // the final answer would lose its buttons.
79
+ if (draftMid && (finalText !== draftLastText || buttons?.length)) {
80
+ try {
81
+ await editMaxMessage(draftMid, finalText, {
82
+ token: account.token,
83
+ format: 'markdown',
84
+ buttons,
85
+ });
86
+ draftLastText = finalText;
87
+ }
88
+ catch {
89
+ /* best effort */
90
+ }
91
+ }
92
+ draftStopped = true;
93
+ },
94
+ clear: async () => {
95
+ if (draftTimer) {
96
+ clearTimeout(draftTimer);
97
+ draftTimer = null;
98
+ }
99
+ draftStopped = true;
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * MAX Messenger Bot API — TypeScript type definitions
3
+ * Based on https://dev.max.ru/docs-api
4
+ */
5
+ export {};
@@ -0,0 +1,215 @@
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
+ /** Updates handled at once across chats; one chat is always sequential. */
15
+ export const MAX_WEBHOOK_CHAT_CONCURRENCY = 4;
16
+ /** Accepted-but-unprocessed updates kept per account before new ones are dropped. */
17
+ export const MAX_WEBHOOK_PENDING_LIMIT = 5000;
18
+ /** How long a stopping consumer waits for in-flight dispatches (gateway stop budget is 5 s). */
19
+ export const MAX_WEBHOOK_STOP_GRACE_MS = 3000;
20
+ /** How long a restarted consumer waits for its predecessor's in-flight dispatches. */
21
+ export const MAX_WEBHOOK_HANDOVER_WAIT_MS = 60_000;
22
+ /** Serialization lane: the chat an update belongs to. */
23
+ export function maxWebhookUpdateLane(update) {
24
+ return String(update.message?.recipient?.chat_id ?? update.chat_id ?? 'global');
25
+ }
26
+ /**
27
+ * Updates acked to MAX but not dispatched when an account task stopped
28
+ * (config reload, channel restart). MAX will not redeliver them, so the next
29
+ * task of the same account in this process picks them up.
30
+ */
31
+ const handovers = new Map();
32
+ /** Open queues per account: a stopping task hands leftovers to an overlapping successor. */
33
+ const openQueues = new Map();
34
+ export function createMaxWebhookUpdateQueue(params) {
35
+ const { accountId } = params;
36
+ const concurrency = Math.max(1, params.concurrency ?? MAX_WEBHOOK_CHAT_CONCURRENCY);
37
+ const pendingLimit = params.pendingLimit ?? MAX_WEBHOOK_PENDING_LIMIT;
38
+ const stopGraceMs = params.stopGraceMs ?? MAX_WEBHOOK_STOP_GRACE_MS;
39
+ const handoverWaitMs = params.handoverWaitMs ?? MAX_WEBHOOK_HANDOVER_WAIT_MS;
40
+ // Taken over at creation: updates pushed later must queue behind them.
41
+ const previous = handovers.get(accountId);
42
+ handovers.delete(accountId);
43
+ const inbox = previous ? [...previous.updates] : [];
44
+ let pending = inbox.length;
45
+ let closed = false;
46
+ let wake;
47
+ let warn;
48
+ const push = (update) => {
49
+ if (closed) {
50
+ // Route still selected this target while the task was stopping.
51
+ const successor = [...siblings].at(-1);
52
+ if (successor)
53
+ return successor(update);
54
+ const handover = handovers.get(accountId);
55
+ if (handover)
56
+ handover.updates.push(update);
57
+ else
58
+ handovers.set(accountId, { updates: [update], settled: Promise.resolve() });
59
+ return;
60
+ }
61
+ if (pending >= pendingLimit) {
62
+ warn?.(`[${accountId}] MAX webhook queue full (${pendingLimit}); dropping ${update.update_type}`);
63
+ return;
64
+ }
65
+ pending += 1;
66
+ inbox.push(update);
67
+ wake?.();
68
+ };
69
+ const siblings = openQueues.get(accountId) ?? new Set();
70
+ siblings.add(push);
71
+ openQueues.set(accountId, siblings);
72
+ /** Stop accepting and pass undispatched updates (lanes first, then inbox) on. */
73
+ const close = (fromLanes, settled) => {
74
+ if (closed)
75
+ return;
76
+ closed = true;
77
+ wake = undefined;
78
+ siblings.delete(push);
79
+ if (siblings.size === 0 && openQueues.get(accountId) === siblings) {
80
+ openQueues.delete(accountId);
81
+ }
82
+ const leftover = [...fromLanes, ...inbox];
83
+ inbox.length = 0;
84
+ pending = 0;
85
+ if (leftover.length === 0)
86
+ return;
87
+ const successor = [...siblings].at(-1);
88
+ if (successor) {
89
+ // A newer task of this account is already consuming: queue there.
90
+ for (const update of leftover)
91
+ successor(update);
92
+ warn?.(`[${accountId}] MAX webhook stopped with ${leftover.length} update(s) not dispatched; moved to the running successor`);
93
+ return;
94
+ }
95
+ const late = handovers.get(accountId)?.updates ?? [];
96
+ handovers.set(accountId, { updates: [...leftover, ...late], settled });
97
+ warn?.(`[${accountId}] MAX webhook stopped with ${leftover.length} update(s) not dispatched; handed over to the next start`);
98
+ };
99
+ const consume = async ({ dispatch, abortSignal, onError, onWarn, }) => {
100
+ warn = onWarn;
101
+ const lanes = new Map();
102
+ const ready = [];
103
+ const active = new Set();
104
+ const inflight = new Set();
105
+ const route = (update) => {
106
+ const lane = maxWebhookUpdateLane(update);
107
+ const queued = lanes.get(lane);
108
+ if (queued)
109
+ queued.push(update);
110
+ else
111
+ lanes.set(lane, [update]);
112
+ if (!active.has(lane) && !ready.includes(lane))
113
+ ready.push(lane);
114
+ };
115
+ const runLane = async (lane) => {
116
+ try {
117
+ for (;;) {
118
+ if (abortSignal.aborted)
119
+ return;
120
+ const update = lanes.get(lane)?.shift();
121
+ if (!update)
122
+ return;
123
+ pending -= 1;
124
+ try {
125
+ await dispatch(update);
126
+ }
127
+ catch (err) {
128
+ onError(err, update);
129
+ }
130
+ }
131
+ }
132
+ finally {
133
+ active.delete(lane);
134
+ if (lanes.get(lane)?.length === 0)
135
+ lanes.delete(lane);
136
+ else if (lanes.has(lane) && !ready.includes(lane))
137
+ ready.push(lane);
138
+ pump();
139
+ }
140
+ };
141
+ // Called only from this consumer's async chain (loop or runLane), so every
142
+ // dispatch inherits the account task's context.
143
+ const pump = () => {
144
+ if (abortSignal.aborted)
145
+ return;
146
+ while (active.size < concurrency && ready.length > 0) {
147
+ const lane = ready.shift();
148
+ active.add(lane);
149
+ const run = runLane(lane);
150
+ inflight.add(run);
151
+ void run.finally(() => inflight.delete(run));
152
+ }
153
+ };
154
+ const nextWake = () => new Promise((resolve) => {
155
+ wake = resolve;
156
+ if (inbox.length > 0 || abortSignal.aborted)
157
+ resolve();
158
+ });
159
+ const onAbort = () => wake?.();
160
+ abortSignal.addEventListener('abort', onAbort, { once: true });
161
+ try {
162
+ if (previous && !abortSignal.aborted) {
163
+ // Keep per-chat order across a restart: the stopped task may still be
164
+ // finishing an update of a chat whose next updates we now hold.
165
+ await waitAtMost(previous.settled, handoverWaitMs, abortSignal);
166
+ }
167
+ while (!abortSignal.aborted) {
168
+ while (inbox.length > 0)
169
+ route(inbox.shift());
170
+ pump();
171
+ await nextWake();
172
+ wake = undefined;
173
+ }
174
+ }
175
+ finally {
176
+ abortSignal.removeEventListener('abort', onAbort);
177
+ const leftover = [];
178
+ for (const queued of lanes.values())
179
+ leftover.push(...queued);
180
+ lanes.clear();
181
+ const settled = Promise.allSettled([...inflight]).then(() => undefined);
182
+ close(leftover, settled);
183
+ await waitAtMost(settled, stopGraceMs);
184
+ }
185
+ };
186
+ return { push, consume, close: () => close([], Promise.resolve()) };
187
+ }
188
+ /** Resolves when `promise` settles, `ms` elapse or `signal` aborts — whichever is first. */
189
+ async function waitAtMost(promise, ms, signal) {
190
+ let timer;
191
+ let onAbort;
192
+ try {
193
+ await Promise.race([
194
+ promise,
195
+ new Promise((resolve) => {
196
+ timer = setTimeout(resolve, ms);
197
+ timer.unref?.();
198
+ if (signal) {
199
+ onAbort = resolve;
200
+ signal.addEventListener('abort', onAbort, { once: true });
201
+ }
202
+ }),
203
+ ]);
204
+ }
205
+ finally {
206
+ clearTimeout(timer);
207
+ if (signal && onAbort)
208
+ signal.removeEventListener('abort', onAbort);
209
+ }
210
+ }
211
+ /** @internal test helper: forget handed-over updates. */
212
+ export function resetMaxWebhookHandoversForTest() {
213
+ handovers.clear();
214
+ openQueues.clear();
215
+ }
@@ -0,0 +1,233 @@
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
+ import { randomBytes } from 'node:crypto';
7
+ import { channelReadyPatch } from 'openclaw/plugin-sdk/gateway-runtime';
8
+ import { readSecretFile } from './accounts.js';
9
+ import { dispatchUpdate } from './dispatch.js';
10
+ import { MAX_SUBSCRIBED_UPDATE_TYPES, } from './monitor-types.js';
11
+ import { registerMaxWebhookRoute, registerMaxWebhookTarget, resolveMaxWebhookPath, subscribeMaxWebhook, } from './webhook.js';
12
+ import { createMaxWebhookUpdateQueue } from './webhook-queue.js';
13
+ /** MAX secret format (SubscriptionRequestBody.secret): 5–256 of [A-Za-z0-9_-]. */
14
+ const MAX_WEBHOOK_SECRET_PATTERN = /^[\w-]{5,256}$/;
15
+ /**
16
+ * Webhook secret: `webhookSecret`, else `webhookSecretFile`, else one generated
17
+ * once and kept in the account state file so restarts re-subscribe with the
18
+ * same value (a fresh secret per start would reject MAX retries of updates
19
+ * sent before the restart).
20
+ * @internal exported for testing.
21
+ */
22
+ export async function resolveMaxWebhookSecret(account, state) {
23
+ const validate = (secret, source) => {
24
+ if (!MAX_WEBHOOK_SECRET_PATTERN.test(secret)) {
25
+ throw new Error(`MAX ${source} must be 5–256 characters of A-Z, a-z, 0-9, _ and -`);
26
+ }
27
+ return secret;
28
+ };
29
+ const configured = account.config.webhookSecret?.trim();
30
+ if (configured)
31
+ return validate(configured, 'webhookSecret');
32
+ const secretFile = account.config.webhookSecretFile?.trim();
33
+ if (secretFile) {
34
+ const fromFile = readSecretFile(secretFile);
35
+ if (!fromFile)
36
+ throw new Error(`MAX webhookSecretFile ${secretFile} is missing, empty or not a regular file`);
37
+ return validate(fromFile, 'webhookSecretFile');
38
+ }
39
+ const stored = state?.webhookSecret;
40
+ if (stored && MAX_WEBHOOK_SECRET_PATTERN.test(stored))
41
+ return stored;
42
+ const generated = generateWebhookSecret();
43
+ if (state) {
44
+ state.setWebhookSecret(generated);
45
+ await state.flush();
46
+ }
47
+ return generated;
48
+ }
49
+ /**
50
+ * Make the MAX side point at exactly one URL: drop this bot's subscriptions to
51
+ * other URLs, then (re)subscribe ours. POST is repeated on every start so the
52
+ * secret and update_types always match the running config.
53
+ * @internal exported for testing.
54
+ */
55
+ export async function syncMaxWebhookSubscription(params) {
56
+ const { api, accountId, webhookUrl, secret, log } = params;
57
+ let existing = [];
58
+ try {
59
+ existing = (await api.getSubscriptions()).subscriptions ?? [];
60
+ }
61
+ catch (err) {
62
+ log?.warn(`[${accountId}] MAX subscriptions check failed, subscribing anyway: ${String(err)}`);
63
+ }
64
+ for (const subscription of existing) {
65
+ if (subscription.url === webhookUrl)
66
+ continue;
67
+ log?.warn(`[${accountId}] MAX webhook: removing subscription to another URL ${subscription.url}`);
68
+ await api.unsubscribe(subscription.url);
69
+ }
70
+ await subscribeMaxWebhook({
71
+ api,
72
+ webhookUrl,
73
+ secret,
74
+ updateTypes: MAX_SUBSCRIBED_UPDATE_TYPES,
75
+ });
76
+ }
77
+ /** How often webhook mode re-checks that MAX still holds our subscription. */
78
+ export const MAX_SUBSCRIPTION_CHECK_INTERVAL_MS = 12 * 60 * 1000;
79
+ /**
80
+ * MAX drops a subscription after 8 hours of failed deliveries (tunnel or
81
+ * gateway outage), and then nothing arrives until the next restart. Re-check
82
+ * periodically and re-subscribe when our URL is gone. Network/API errors on
83
+ * the check are only logged; the next tick tries again.
84
+ * Returns a stop function; the timer is also cleared when abortSignal fires.
85
+ * @internal exported for testing.
86
+ */
87
+ export function startMaxSubscriptionWatch(params) {
88
+ const { api, accountId, webhookUrl, secret, abortSignal, log } = params;
89
+ let running = false;
90
+ const check = async () => {
91
+ if (running || abortSignal.aborted)
92
+ return;
93
+ running = true;
94
+ try {
95
+ let subscriptions;
96
+ try {
97
+ subscriptions = (await api.getSubscriptions()).subscriptions ?? [];
98
+ }
99
+ catch (err) {
100
+ log?.warn(`[${accountId}] MAX subscription check failed: ${String(err)}`);
101
+ return;
102
+ }
103
+ if (abortSignal.aborted || subscriptions.some((s) => s.url === webhookUrl))
104
+ return;
105
+ log?.warn(`[${accountId}] MAX webhook subscription to ${webhookUrl} is gone (MAX unsubscribes after 8 h of failed deliveries); re-subscribing`);
106
+ try {
107
+ await subscribeMaxWebhook({
108
+ api,
109
+ webhookUrl,
110
+ secret,
111
+ updateTypes: MAX_SUBSCRIBED_UPDATE_TYPES,
112
+ });
113
+ log?.info(`[${accountId}] MAX webhook re-subscribed: ${webhookUrl}`);
114
+ }
115
+ catch (err) {
116
+ log?.error(`[${accountId}] MAX webhook re-subscribe failed: ${String(err)}`);
117
+ }
118
+ }
119
+ finally {
120
+ running = false;
121
+ }
122
+ };
123
+ const timer = setInterval(() => void check(), params.intervalMs ?? MAX_SUBSCRIPTION_CHECK_INTERVAL_MS);
124
+ timer.unref?.();
125
+ const stop = () => {
126
+ clearInterval(timer);
127
+ abortSignal.removeEventListener('abort', stop);
128
+ };
129
+ abortSignal.addEventListener('abort', stop, { once: true });
130
+ return stop;
131
+ }
132
+ export async function startMaxWebhook(opts) {
133
+ const { api, account, config, abortSignal, log, statusSink } = opts;
134
+ const webhookUrl = account.config.webhookUrl?.trim();
135
+ if (!webhookUrl) {
136
+ throw new Error(`MAX transport "webhook" requires webhookUrl (account ${account.accountId})`);
137
+ }
138
+ if (!webhookUrl.startsWith('https://')) {
139
+ log?.warn(`[${account.accountId}] MAX accepts only HTTPS webhook URLs on port 443; got ${webhookUrl}`);
140
+ }
141
+ const webhookPath = resolveMaxWebhookPath(account.config.webhookPath, webhookUrl);
142
+ const webhookSecret = await resolveMaxWebhookSecret(account, opts.state);
143
+ log?.info(`[${account.accountId}] MAX webhook mode: ${webhookUrl} (path: ${webhookPath})`);
144
+ // Like Telegram webhook mode: no transport-activity timestamp, so the health
145
+ // policy never flags a quiet (but healthy) webhook as a stale socket.
146
+ statusSink?.({
147
+ mode: 'webhook',
148
+ connected: false,
149
+ lastConnectedAt: null,
150
+ lastEventAt: null,
151
+ lastTransportActivityAt: null,
152
+ });
153
+ // MAX requires HTTP 200 within 30s while agent runs regularly take minutes.
154
+ // The route handler acks and only enqueues; this account task dispatches
155
+ // (see webhook-queue.ts: dispatching from the request context fails with
156
+ // GatewayDrainingError once the request's work admission is released).
157
+ const queue = createMaxWebhookUpdateQueue({ accountId: account.accountId });
158
+ const onUpdate = (update) => {
159
+ const at = Date.now();
160
+ statusSink?.(channelReadyPatch({
161
+ lastConnectedAt: at,
162
+ lastEventAt: at,
163
+ mode: 'webhook',
164
+ }));
165
+ queue.push(update);
166
+ return Promise.resolve();
167
+ };
168
+ const target = {
169
+ account,
170
+ config,
171
+ path: webhookPath,
172
+ secret: webhookSecret,
173
+ onUpdate,
174
+ log: (msg) => log?.debug?.(msg),
175
+ error: (msg) => log?.error(msg),
176
+ };
177
+ const unregisterTarget = registerMaxWebhookTarget(target);
178
+ let unregisterRoute = () => { };
179
+ try {
180
+ // Route first: MAX may deliver the moment the subscription exists.
181
+ unregisterRoute = registerMaxWebhookRoute({
182
+ path: webhookPath,
183
+ accountId: account.accountId,
184
+ log: (msg) => log?.warn(`[${account.accountId}] ${msg}`),
185
+ register: opts.registerWebhookRoute,
186
+ });
187
+ await syncMaxWebhookSubscription({
188
+ api,
189
+ accountId: account.accountId,
190
+ webhookUrl,
191
+ secret: webhookSecret,
192
+ log,
193
+ });
194
+ log?.info(`[${account.accountId}] MAX webhook subscribed: ${webhookUrl}`);
195
+ }
196
+ catch (err) {
197
+ log?.error(`[${account.accountId}] MAX webhook start failed: ${String(err)}`);
198
+ unregisterRoute();
199
+ unregisterTarget();
200
+ queue.close();
201
+ statusSink?.({ mode: 'webhook', connected: false, lastError: String(err) });
202
+ throw err;
203
+ }
204
+ statusSink?.(channelReadyPatch({ mode: 'webhook' }));
205
+ const stopSubscriptionWatch = startMaxSubscriptionWatch({
206
+ api,
207
+ accountId: account.accountId,
208
+ webhookUrl,
209
+ secret: webhookSecret,
210
+ abortSignal,
211
+ log,
212
+ });
213
+ // Runs until abort; every dispatch starts here, under the account task.
214
+ await queue.consume({
215
+ dispatch: (update) => dispatchUpdate(update, opts),
216
+ abortSignal,
217
+ onError: (err, update) => log?.error(`[${account.accountId}] Webhook update dispatch failed (${update.update_type}): ${String(err)}`),
218
+ onWarn: (message) => log?.warn(message),
219
+ });
220
+ stopSubscriptionWatch();
221
+ // The subscription is kept on purpose: a restart or config reload must not
222
+ // lose updates, and MAX retries undelivered ones while the route is back.
223
+ // To leave webhook mode, switch transport to "polling" (the polling start
224
+ // removes it) or call DELETE /subscriptions?url=… by hand.
225
+ unregisterRoute();
226
+ unregisterTarget();
227
+ statusSink?.({ mode: 'webhook', connected: false });
228
+ log?.info(`[${account.accountId}] MAX webhook mode stopped (subscription kept)`);
229
+ }
230
+ /** MAX webhook secret: 5–256 chars of [A-Za-z0-9-]. */
231
+ function generateWebhookSecret() {
232
+ return randomBytes(24).toString('base64url').replace(/_/g, '-');
233
+ }