@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
package/src/webhook.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * MAX webhook transport — HTTP ingress on the gateway server and the
3
+ * subscription lifecycle (GET/POST/DELETE /subscriptions).
4
+ *
5
+ * The route is registered dynamically from the account lifecycle through
6
+ * `registerPluginHttpRoute` (plugin-sdk/webhook-ingress): it lives exactly as
7
+ * long as the account task, carries plugin-managed auth (the MAX shared
8
+ * secret), and a failed registration fails the account start instead of
9
+ * reporting ready without live ingress.
10
+ */
11
+
12
+ import { timingSafeEqual } from 'node:crypto';
13
+ import type { IncomingMessage, ServerResponse } from 'node:http';
14
+
15
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
16
+ import {
17
+ normalizeWebhookPath,
18
+ readJsonWebhookBodyOrReject,
19
+ registerPluginHttpRoute,
20
+ resolveWebhookPath,
21
+ } from 'openclaw/plugin-sdk/webhook-ingress';
22
+
23
+ import type { ResolvedMaxAccount } from './accounts.js';
24
+ import type { MaxUpdate } from './api.js';
25
+ import type { MaxApi } from './api.js';
26
+
27
+ /** Plugin id from openclaw.plugin.json — the owner of the gateway route. */
28
+ export const MAX_PLUGIN_ID = 'openclaw-max';
29
+ /** Stable same-plugin route sub-owner (see gateway-routes docs). */
30
+ export const MAX_WEBHOOK_ROUTE_SOURCE = 'max-webhook';
31
+ export const DEFAULT_MAX_WEBHOOK_PATH = '/max/webhook';
32
+
33
+ /** Header MAX sends with the subscription secret. */
34
+ const SECRET_HEADER = 'x-max-bot-api-secret';
35
+ const DEDUPE_CAPACITY = 1000;
36
+
37
+ function secretsMatch(expected: string, provided: string): boolean {
38
+ const a = Buffer.from(expected);
39
+ const b = Buffer.from(provided);
40
+ if (a.length !== b.length) return false;
41
+ return timingSafeEqual(a, b);
42
+ }
43
+
44
+ export type MaxWebhookTarget = {
45
+ account: ResolvedMaxAccount;
46
+ config: OpenClawConfig;
47
+ path: string;
48
+ secret?: string;
49
+ onUpdate: (update: MaxUpdate) => Promise<void>;
50
+ log?: (message: string) => void;
51
+ error?: (message: string) => void;
52
+ };
53
+
54
+ type RegisteredTarget = MaxWebhookTarget & { seen: Map<string, true> };
55
+
56
+ const webhookTargets = new Map<string, RegisteredTarget[]>();
57
+
58
+ export function resolveMaxWebhookPath(webhookPath?: string, webhookUrl?: string): string {
59
+ return (
60
+ resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: DEFAULT_MAX_WEBHOOK_PATH }) ??
61
+ DEFAULT_MAX_WEBHOOK_PATH
62
+ );
63
+ }
64
+
65
+ export function registerMaxWebhookTarget(target: MaxWebhookTarget): () => void {
66
+ const key = normalizeWebhookPath(target.path);
67
+ const normalizedTarget: RegisteredTarget = { ...target, path: key, seen: new Map() };
68
+ webhookTargets.set(key, [...(webhookTargets.get(key) ?? []), normalizedTarget]);
69
+
70
+ let active = true;
71
+ return () => {
72
+ if (!active) return;
73
+ active = false;
74
+ const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
75
+ if (updated.length > 0) {
76
+ webhookTargets.set(key, updated);
77
+ } else {
78
+ webhookTargets.delete(key);
79
+ }
80
+ };
81
+ }
82
+
83
+ export type RegisterMaxWebhookRoute = typeof registerPluginHttpRoute;
84
+
85
+ /**
86
+ * Register the gateway HTTP route for a webhook path. Canonically equal paths
87
+ * of several accounts share one route: the handler picks the target by secret.
88
+ */
89
+ export function registerMaxWebhookRoute(params: {
90
+ path: string;
91
+ accountId: string;
92
+ log?: (message: string) => void;
93
+ register?: RegisterMaxWebhookRoute;
94
+ }): () => void {
95
+ const register = params.register ?? registerPluginHttpRoute;
96
+ return register({
97
+ path: normalizeWebhookPath(params.path),
98
+ auth: 'plugin',
99
+ match: 'exact',
100
+ pluginId: MAX_PLUGIN_ID,
101
+ source: MAX_WEBHOOK_ROUTE_SOURCE,
102
+ accountId: params.accountId,
103
+ replaceExisting: true,
104
+ throwOnFailure: true,
105
+ log: params.log,
106
+ handler: handleMaxWebhookRequest,
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Key for redelivery dedupe: MAX retries a delivery that was not acknowledged
112
+ * with 200 in time, so the same update can arrive twice.
113
+ * @internal exported for testing.
114
+ */
115
+ export function maxUpdateDedupeKey(update: MaxUpdate): string | undefined {
116
+ const id =
117
+ update.callback?.callback_id ??
118
+ update.message?.body?.mid ??
119
+ (typeof update.message_id === 'string' ? update.message_id : undefined);
120
+ if (!id && update.timestamp == null) return undefined;
121
+ return `${update.update_type}:${update.timestamp ?? ''}:${id ?? ''}`;
122
+ }
123
+
124
+ /** Returns true when the key was already seen (LRU bounded by DEDUPE_CAPACITY). */
125
+ function rememberUpdate(seen: Map<string, true>, key: string): boolean {
126
+ if (seen.has(key)) {
127
+ seen.delete(key);
128
+ seen.set(key, true);
129
+ return true;
130
+ }
131
+ seen.set(key, true);
132
+ if (seen.size > DEDUPE_CAPACITY) {
133
+ const oldest = seen.keys().next().value;
134
+ if (oldest !== undefined) seen.delete(oldest);
135
+ }
136
+ return false;
137
+ }
138
+
139
+ export async function handleMaxWebhookRequest(
140
+ req: IncomingMessage,
141
+ res: ServerResponse,
142
+ ): Promise<boolean> {
143
+ const url = new URL(req.url ?? '/', 'http://localhost');
144
+ const path = normalizeWebhookPath(url.pathname);
145
+ const targets = webhookTargets.get(path);
146
+
147
+ if (!targets || targets.length === 0) {
148
+ return false;
149
+ }
150
+
151
+ if (req.method !== 'POST') {
152
+ res.statusCode = 405;
153
+ res.setHeader('Allow', 'POST');
154
+ res.end('Method Not Allowed');
155
+ return true;
156
+ }
157
+
158
+ // Authenticate before reading the body. Targets without a secret never
159
+ // match: an unauthenticated webhook would let anyone inject updates.
160
+ const rawSecret = req.headers[SECRET_HEADER];
161
+ const providedSecret = Array.isArray(rawSecret) ? (rawSecret[0] ?? '') : (rawSecret ?? '');
162
+ const matchedTarget = providedSecret
163
+ ? targets.find((target) => target.secret && secretsMatch(target.secret, providedSecret))
164
+ : undefined;
165
+
166
+ if (!matchedTarget) {
167
+ res.statusCode = 401;
168
+ res.end('Unauthorized');
169
+ return true;
170
+ }
171
+
172
+ // Size/timeout/closed-connection/malformed-JSON failures are answered by the
173
+ // SDK helper (413/408/400) — it has already written the response.
174
+ const body = await readJsonWebhookBodyOrReject({
175
+ req,
176
+ res,
177
+ maxBytes: 1024 * 1024,
178
+ timeoutMs: 30_000,
179
+ emptyObjectOnEmpty: false,
180
+ invalidJsonMessage: 'invalid payload',
181
+ });
182
+ if (!body.ok) {
183
+ return true;
184
+ }
185
+
186
+ const raw = body.value;
187
+ if (
188
+ !raw ||
189
+ typeof raw !== 'object' ||
190
+ Array.isArray(raw) ||
191
+ typeof (raw as { update_type?: unknown }).update_type !== 'string'
192
+ ) {
193
+ res.statusCode = 400;
194
+ res.end('invalid payload');
195
+ return true;
196
+ }
197
+
198
+ const update = raw as MaxUpdate;
199
+
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
+ const dedupeKey = maxUpdateDedupeKey(update);
211
+ if (dedupeKey && rememberUpdate(matchedTarget.seen, dedupeKey)) {
212
+ matchedTarget.log?.(
213
+ `[${matchedTarget.account.accountId}] MAX webhook: duplicate ${dedupeKey} skipped`,
214
+ );
215
+ return true;
216
+ }
217
+
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
+ });
225
+
226
+ return true;
227
+ }
228
+
229
+ /**
230
+ * Subscribe to MAX webhook
231
+ */
232
+ export async function subscribeMaxWebhook(params: {
233
+ api: MaxApi;
234
+ webhookUrl: string;
235
+ secret?: string;
236
+ updateTypes?: string[];
237
+ }): Promise<void> {
238
+ const { api, webhookUrl, secret, updateTypes } = params;
239
+
240
+ await api.subscribe({
241
+ url: webhookUrl,
242
+ update_types: updateTypes ?? [
243
+ 'message_created',
244
+ 'message_callback',
245
+ 'message_edited',
246
+ 'message_removed',
247
+ 'bot_started',
248
+ 'bot_stopped',
249
+ 'bot_added',
250
+ 'bot_removed',
251
+ 'dialog_cleared',
252
+ 'dialog_removed',
253
+ 'chat_title_changed',
254
+ ],
255
+ secret,
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Unsubscribe from MAX webhook
261
+ */
262
+ export async function unsubscribeMaxWebhook(params: {
263
+ api: MaxApi;
264
+ webhookUrl: string;
265
+ }): Promise<void> {
266
+ const { api, webhookUrl } = params;
267
+ await api.unsubscribe(webhookUrl);
268
+ }