@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,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared monitor contracts: options every transport and handler receives,
|
|
3
|
+
* the gateway status patch shape, and the update types this channel consumes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ChannelAccountSnapshot, ChannelLogSink } from 'openclaw/plugin-sdk/channel-contract';
|
|
7
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
|
|
8
|
+
|
|
9
|
+
import type { MaxAccountConfig, ResolvedMaxAccount } from './accounts.js';
|
|
10
|
+
import type { MaxApi, MaxUpdateType } from './api.js';
|
|
11
|
+
import type { MaxStateStore } from './state.js';
|
|
12
|
+
import type { RegisterMaxWebhookRoute } from './webhook.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runtime status patches published to the gateway. Activity timestamps are not
|
|
16
|
+
* enough: the gateway leaves an account in `lifecycle: "starting"` until the
|
|
17
|
+
* transport reports `connected`, and its health policy can only detect a dead
|
|
18
|
+
* socket from `lastTransportActivityAt`.
|
|
19
|
+
*/
|
|
20
|
+
export type MaxStatusPatch = Partial<ChannelAccountSnapshot>;
|
|
21
|
+
|
|
22
|
+
export interface MaxMonitorOptions {
|
|
23
|
+
api: MaxApi;
|
|
24
|
+
account: ResolvedMaxAccount;
|
|
25
|
+
config: OpenClawConfig;
|
|
26
|
+
abortSignal: AbortSignal;
|
|
27
|
+
botUserId?: number;
|
|
28
|
+
botUsername?: string;
|
|
29
|
+
log?: ChannelLogSink;
|
|
30
|
+
statusSink?: (patch: MaxStatusPatch) => void;
|
|
31
|
+
/** Persistent marker + chat registry (created by startMaxPolling when absent) */
|
|
32
|
+
state?: MaxStateStore;
|
|
33
|
+
/** Gateway route registration; defaults to plugin-sdk registerPluginHttpRoute (tests inject a mock) */
|
|
34
|
+
registerWebhookRoute?: RegisterMaxWebhookRoute;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Update types this channel consumes. Shared by long polling (types filter)
|
|
39
|
+
* and webhook subscriptions (update_types).
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_SUBSCRIBED_UPDATE_TYPES: MaxUpdateType[] = [
|
|
42
|
+
'message_created',
|
|
43
|
+
'message_callback',
|
|
44
|
+
'message_edited',
|
|
45
|
+
'message_removed',
|
|
46
|
+
'bot_started',
|
|
47
|
+
'bot_stopped',
|
|
48
|
+
'bot_added',
|
|
49
|
+
'bot_removed',
|
|
50
|
+
'dialog_cleared',
|
|
51
|
+
'dialog_removed',
|
|
52
|
+
'chat_title_changed',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export type MaxTransport = 'polling' | 'webhook';
|
|
56
|
+
|
|
57
|
+
/** Explicit `transport` wins; otherwise a configured webhookUrl selects webhook mode. */
|
|
58
|
+
export function resolveMaxTransport(config: MaxAccountConfig): MaxTransport {
|
|
59
|
+
if (config.transport === 'polling' || config.transport === 'webhook') return config.transport;
|
|
60
|
+
return config.webhookUrl?.trim() ? 'webhook' : 'polling';
|
|
61
|
+
}
|
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MAX monitor — receives updates (long polling or webhook) and dispatches them
|
|
3
|
+
* to OpenClaw.
|
|
4
|
+
*
|
|
5
|
+
* Uses the same inbound pipeline as other channel plugins:
|
|
6
|
+
* finalizeInboundContext → dispatchReplyWithBufferedBlockDispatcher
|
|
7
|
+
*
|
|
8
|
+
* Roles: polling.ts / webhook-runner.ts (transports) → dispatch.ts (by
|
|
9
|
+
* update_type) → inbound.ts (gates, context, agent run; attachments in
|
|
10
|
+
* inbound-attachments.ts) / callbacks.ts (button presses) → deliver.ts and
|
|
11
|
+
* stream-draft.ts (replies).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type MaxMonitorOptions, resolveMaxTransport } from './monitor-types.js';
|
|
15
|
+
import { clearMaxSubscriptionsForPolling, startMaxPollingLoop } from './polling.js';
|
|
16
|
+
import { MaxStateStore } from './state.js';
|
|
17
|
+
import { startMaxWebhook } from './webhook-runner.js';
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
MAX_SUBSCRIBED_UPDATE_TYPES,
|
|
21
|
+
type MaxMonitorOptions,
|
|
22
|
+
type MaxStatusPatch,
|
|
23
|
+
type MaxTransport,
|
|
24
|
+
resolveMaxTransport,
|
|
25
|
+
} from './monitor-types.js';
|
|
26
|
+
|
|
27
|
+
export async function startMaxPolling(opts: MaxMonitorOptions): Promise<void> {
|
|
28
|
+
const { account, log } = opts;
|
|
29
|
+
|
|
30
|
+
// Persistent state: polling marker + chat registry + webhook secret
|
|
31
|
+
if (!opts.state) {
|
|
32
|
+
opts.state = new MaxStateStore(account.accountId, (err) => {
|
|
33
|
+
log?.error(`[${account.accountId}] MAX state persist failed: ${String(err)}`);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
await opts.state.load();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
log?.error(`[${account.accountId}] MAX state load failed: ${String(err)}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (resolveMaxTransport(account.config) === 'webhook') {
|
|
43
|
+
await startMaxWebhook(opts);
|
|
44
|
+
} else {
|
|
45
|
+
// An active subscription silently disables long polling on the MAX side.
|
|
46
|
+
await clearMaxSubscriptionsForPolling(opts);
|
|
47
|
+
await startMaxPollingLoop(opts);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MAX channel onboarding — setup wizard for `openclaw channel add max`
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DmPolicy } from 'openclaw/plugin-sdk/config-contracts';
|
|
6
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
|
|
7
|
+
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/core';
|
|
8
|
+
import type { ChannelSetupDmPolicy, ChannelSetupWizard } from 'openclaw/plugin-sdk/setup';
|
|
9
|
+
import { formatDocsLink } from 'openclaw/plugin-sdk/setup';
|
|
10
|
+
|
|
11
|
+
import { listMaxAccountIds, resolveMaxAccount } from './accounts.js';
|
|
12
|
+
import { MaxApi } from './api.js';
|
|
13
|
+
|
|
14
|
+
const channel = 'max' as const;
|
|
15
|
+
|
|
16
|
+
const ENV_MAX_BOT_TOKEN = 'MAX_BOT_TOKEN';
|
|
17
|
+
|
|
18
|
+
function setMaxDmPolicy(cfg: OpenClawConfig, policy: DmPolicy): OpenClawConfig {
|
|
19
|
+
return {
|
|
20
|
+
...cfg,
|
|
21
|
+
channels: {
|
|
22
|
+
...cfg.channels,
|
|
23
|
+
max: {
|
|
24
|
+
...cfg.channels?.['max'],
|
|
25
|
+
dmPolicy: policy,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const dmPolicy: ChannelSetupDmPolicy = {
|
|
32
|
+
label: 'MAX',
|
|
33
|
+
channel,
|
|
34
|
+
policyKey: 'channels.max.dmPolicy',
|
|
35
|
+
allowFromKey: 'channels.max.allowFrom',
|
|
36
|
+
getCurrent: (cfg: OpenClawConfig) => cfg.channels?.['max']?.dmPolicy ?? 'pairing',
|
|
37
|
+
setPolicy: (cfg: OpenClawConfig, policy: DmPolicy) => setMaxDmPolicy(cfg, policy),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function applyAccountConfig(params: {
|
|
41
|
+
cfg: OpenClawConfig;
|
|
42
|
+
accountId: string;
|
|
43
|
+
patch: Record<string, unknown>;
|
|
44
|
+
}): OpenClawConfig {
|
|
45
|
+
const { cfg, accountId, patch } = params;
|
|
46
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
47
|
+
return {
|
|
48
|
+
...cfg,
|
|
49
|
+
channels: {
|
|
50
|
+
...cfg.channels,
|
|
51
|
+
max: {
|
|
52
|
+
...cfg.channels?.['max'],
|
|
53
|
+
enabled: true,
|
|
54
|
+
...patch,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
...cfg,
|
|
61
|
+
channels: {
|
|
62
|
+
...cfg.channels,
|
|
63
|
+
max: {
|
|
64
|
+
...cfg.channels?.['max'],
|
|
65
|
+
enabled: true,
|
|
66
|
+
accounts: {
|
|
67
|
+
...cfg.channels?.['max']?.accounts,
|
|
68
|
+
[accountId]: {
|
|
69
|
+
...cfg.channels?.['max']?.accounts?.[accountId],
|
|
70
|
+
enabled: true,
|
|
71
|
+
...patch,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const maxSetupWizard: ChannelSetupWizard = {
|
|
80
|
+
channel,
|
|
81
|
+
|
|
82
|
+
status: {
|
|
83
|
+
configuredLabel: 'MAX (configured)',
|
|
84
|
+
unconfiguredLabel: 'MAX Messenger',
|
|
85
|
+
configuredHint: 'configured',
|
|
86
|
+
unconfiguredHint: 'needs bot token',
|
|
87
|
+
resolveConfigured: ({ cfg }) => {
|
|
88
|
+
return listMaxAccountIds(cfg).some(
|
|
89
|
+
(accountId) => resolveMaxAccount({ cfg, accountId }).tokenSource !== 'none',
|
|
90
|
+
);
|
|
91
|
+
},
|
|
92
|
+
resolveStatusLines: ({ configured }) => {
|
|
93
|
+
return [`MAX: ${configured ? 'configured' : 'needs bot token'}`];
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
introNote: {
|
|
98
|
+
title: 'MAX setup',
|
|
99
|
+
lines: [
|
|
100
|
+
'MAX messenger bot requires a bot token from https://platform-api.max.ru',
|
|
101
|
+
'Create your bot via MAX Business or MAX API portal.',
|
|
102
|
+
'The bot can receive messages via polling (default) or webhook.',
|
|
103
|
+
`Docs: ${formatDocsLink('/channels/max', 'channels/max')}`,
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
envShortcut: {
|
|
108
|
+
prompt: 'Use MAX_BOT_TOKEN env var?',
|
|
109
|
+
preferredEnvVar: ENV_MAX_BOT_TOKEN,
|
|
110
|
+
isAvailable: ({ accountId }) => {
|
|
111
|
+
return accountId === DEFAULT_ACCOUNT_ID && Boolean(process.env[ENV_MAX_BOT_TOKEN]);
|
|
112
|
+
},
|
|
113
|
+
apply: ({ cfg, accountId }) => {
|
|
114
|
+
return applyAccountConfig({ cfg, accountId, patch: {} });
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
credentials: [
|
|
119
|
+
{
|
|
120
|
+
inputKey: 'botToken',
|
|
121
|
+
providerHint: 'MAX bot token',
|
|
122
|
+
credentialLabel: 'bot token',
|
|
123
|
+
preferredEnvVar: ENV_MAX_BOT_TOKEN,
|
|
124
|
+
envPrompt: 'Use MAX_BOT_TOKEN env var?',
|
|
125
|
+
keepPrompt: 'Keep current bot token?',
|
|
126
|
+
inputPrompt: 'MAX bot token',
|
|
127
|
+
|
|
128
|
+
inspect: ({ cfg, accountId }) => {
|
|
129
|
+
const account = resolveMaxAccount({ cfg, accountId });
|
|
130
|
+
const hasToken = Boolean(account.token);
|
|
131
|
+
const envValue =
|
|
132
|
+
accountId === DEFAULT_ACCOUNT_ID ? process.env[ENV_MAX_BOT_TOKEN] : undefined;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
accountConfigured: hasToken,
|
|
136
|
+
hasConfiguredValue: hasToken,
|
|
137
|
+
resolvedValue: account.token || undefined,
|
|
138
|
+
envValue,
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
|
|
143
|
+
|
|
144
|
+
applyUseEnv: ({ cfg, accountId }) => {
|
|
145
|
+
return applyAccountConfig({ cfg, accountId, patch: {} });
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
applySet: async ({ cfg, accountId, resolvedValue }) => {
|
|
149
|
+
const tokenValue = String(resolvedValue).trim();
|
|
150
|
+
|
|
151
|
+
// Verify token by calling GET /me
|
|
152
|
+
try {
|
|
153
|
+
const api = new MaxApi({ token: tokenValue, timeoutMs: 5000 });
|
|
154
|
+
const me = await api.getMe();
|
|
155
|
+
console.log(
|
|
156
|
+
`✓ Token verified! Bot: ${me.first_name}${me.username ? ` (@${me.username})` : ''} (ID: ${me.user_id})`,
|
|
157
|
+
);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
160
|
+
console.warn(`⚠ Token verification failed: ${errorMsg}`);
|
|
161
|
+
// Continue anyway - user will see the error when gateway starts
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return applyAccountConfig({
|
|
165
|
+
cfg,
|
|
166
|
+
accountId,
|
|
167
|
+
patch: { botToken: tokenValue },
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
|
|
173
|
+
textInputs: [
|
|
174
|
+
{
|
|
175
|
+
inputKey: 'tokenFile',
|
|
176
|
+
message: 'Token file path (optional)',
|
|
177
|
+
placeholder: '/path/to/max-token.txt',
|
|
178
|
+
required: false,
|
|
179
|
+
|
|
180
|
+
shouldPrompt: ({ credentialValues }) => {
|
|
181
|
+
// Only prompt if no token was set via credential
|
|
182
|
+
return !credentialValues.botToken;
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
applySet: ({ cfg, accountId, value }) => {
|
|
186
|
+
const path = value.trim();
|
|
187
|
+
if (!path) return cfg;
|
|
188
|
+
|
|
189
|
+
return applyAccountConfig({
|
|
190
|
+
cfg,
|
|
191
|
+
accountId,
|
|
192
|
+
patch: { tokenFile: path },
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
|
|
198
|
+
finalize: ({ cfg }) => {
|
|
199
|
+
// Ensure the channel is enabled
|
|
200
|
+
return {
|
|
201
|
+
cfg: {
|
|
202
|
+
...cfg,
|
|
203
|
+
channels: {
|
|
204
|
+
...cfg.channels,
|
|
205
|
+
max: {
|
|
206
|
+
...cfg.channels?.['max'],
|
|
207
|
+
enabled: true,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
dmPolicy,
|
|
215
|
+
};
|
package/src/polling.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long-polling transport: GET /updates loop with a persisted marker, and the
|
|
3
|
+
* start-up cleanup of webhook subscriptions that would silence polling.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
channelReadyPatch,
|
|
8
|
+
createTransportActivityStatusPatch,
|
|
9
|
+
} from 'openclaw/plugin-sdk/gateway-runtime';
|
|
10
|
+
|
|
11
|
+
import type { MaxSubscription } from './api.js';
|
|
12
|
+
import { dispatchUpdate } from './dispatch.js';
|
|
13
|
+
import {
|
|
14
|
+
MAX_SUBSCRIBED_UPDATE_TYPES,
|
|
15
|
+
type MaxMonitorOptions,
|
|
16
|
+
type MaxStatusPatch,
|
|
17
|
+
} from './monitor-types.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Polling mode: MAX stops serving GET /updates while any webhook subscription
|
|
21
|
+
* exists, so a leftover subscription (another deploy, a manual test) would
|
|
22
|
+
* leave the bot deaf without a single error. Remove it and say so.
|
|
23
|
+
* @internal exported for testing.
|
|
24
|
+
*/
|
|
25
|
+
export async function clearMaxSubscriptionsForPolling(
|
|
26
|
+
opts: Pick<MaxMonitorOptions, 'api' | 'account' | 'log'>,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const { api, account, log } = opts;
|
|
29
|
+
let subscriptions: MaxSubscription[];
|
|
30
|
+
try {
|
|
31
|
+
subscriptions = (await api.getSubscriptions()).subscriptions ?? [];
|
|
32
|
+
} catch (err) {
|
|
33
|
+
log?.warn(`[${account.accountId}] MAX subscriptions check failed: ${String(err)}`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const subscription of subscriptions) {
|
|
37
|
+
log?.warn(
|
|
38
|
+
`[${account.accountId}] MAX webhook subscription ${subscription.url} is active — long polling gets no updates while it exists; removing it`,
|
|
39
|
+
);
|
|
40
|
+
try {
|
|
41
|
+
await api.unsubscribe(subscription.url);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
log?.error(
|
|
44
|
+
`[${account.accountId}] MAX unsubscribe ${subscription.url} failed: ${String(err)}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function startMaxPollingLoop(opts: MaxMonitorOptions): Promise<void> {
|
|
51
|
+
const { api, account, abortSignal, log, statusSink } = opts;
|
|
52
|
+
let marker: number | null = opts.state?.marker ?? null;
|
|
53
|
+
|
|
54
|
+
log?.info(
|
|
55
|
+
`[${account.accountId}] MAX long-polling started${marker != null ? ` (resuming from marker ${marker})` : ''}`,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
while (!abortSignal.aborted) {
|
|
59
|
+
try {
|
|
60
|
+
// The channel abort signal must reach the request: a 35s long poll that
|
|
61
|
+
// only the request's own timeout can cancel outlives the gateway's 5s
|
|
62
|
+
// stop budget and logs "channel stop exceeded 5000ms after abort".
|
|
63
|
+
const resp = await api.getUpdates({
|
|
64
|
+
timeout: 30,
|
|
65
|
+
marker: marker ?? undefined,
|
|
66
|
+
types: MAX_SUBSCRIBED_UPDATE_TYPES,
|
|
67
|
+
signal: abortSignal,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// A completed poll is the transport proof the gateway waits for: it moves
|
|
71
|
+
// the account out of lifecycle "starting", clears a stale lastError, and
|
|
72
|
+
// refreshes the timestamp the health policy uses to spot a dead socket.
|
|
73
|
+
statusSink?.(
|
|
74
|
+
channelReadyPatch({
|
|
75
|
+
...createTransportActivityStatusPatch(),
|
|
76
|
+
mode: 'polling',
|
|
77
|
+
}) as MaxStatusPatch,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Advance the in-memory marker so the next poll in this process moves on…
|
|
81
|
+
if (resp.marker != null) {
|
|
82
|
+
marker = resp.marker;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let batchCompleted = true;
|
|
86
|
+
for (const update of resp.updates) {
|
|
87
|
+
if (abortSignal.aborted) {
|
|
88
|
+
batchCompleted = false;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
await dispatchUpdate(update, opts);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
log?.error(
|
|
95
|
+
`[${account.accountId}] Error dispatching update ${update.update_type}: ${String(err)}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// …but only PERSIST the marker after the whole batch is handled. A restart
|
|
101
|
+
// mid-batch then resumes from before the unprocessed updates (at-least-once);
|
|
102
|
+
// OpenClaw dedups replays by mid, so re-delivery is safe but loss is not.
|
|
103
|
+
if (batchCompleted && !abortSignal.aborted && resp.marker != null) {
|
|
104
|
+
opts.state?.setMarker(resp.marker);
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (abortSignal.aborted) break;
|
|
108
|
+
log?.error(`[${account.accountId}] Polling error: ${String(err)}`);
|
|
109
|
+
// Record the error but keep `connected` untouched: this loop owns its
|
|
110
|
+
// retries, and flipping to disconnected on a transient blip would hand
|
|
111
|
+
// the health monitor a restart trigger. A genuinely dead transport is
|
|
112
|
+
// caught by lastTransportActivityAt going stale instead.
|
|
113
|
+
statusSink?.({ lastError: String(err) } as MaxStatusPatch);
|
|
114
|
+
// Back off on error
|
|
115
|
+
await sleep(3000);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
statusSink?.({ connected: false } as MaxStatusPatch);
|
|
120
|
+
log?.info(`[${account.accountId}] MAX long-polling stopped`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sleep(ms: number): Promise<void> {
|
|
124
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
125
|
+
}
|