@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,140 @@
1
+ /**
2
+ * Button presses (message_callback): presentation approvals and ask_user
3
+ * answers go to the gateway runtimes; other payloads re-enter the inbound
4
+ * pipeline as a synthesized message.
5
+ */
6
+
7
+ import { resolveApprovalOverGateway } from 'openclaw/plugin-sdk/approval-gateway-runtime';
8
+ import { questionGatewayRuntime } from 'openclaw/plugin-sdk/question-gateway-runtime';
9
+
10
+ import type { ResolvedMaxAccount } from './accounts.js';
11
+ import type { MaxCallback, MaxMessage } from './api.js';
12
+ import { processIncomingMessage } from './inbound.js';
13
+ import type { MaxMonitorOptions } from './monitor-types.js';
14
+ import { decodeMaxPresentationCallback, type MaxPresentationCallback } from './presentation.js';
15
+
16
+ /**
17
+ * Synthesize a regular message from a button press. The keyboard's message is
18
+ * a sibling of `callback` in the update; its recipient is the real chat
19
+ * (dialog chat_id ≠ user_id). Only when that message is gone (null) do we fall
20
+ * back to addressing the pressing user.
21
+ * callback_id is not a valid MAX message id, so replies to callback-originated
22
+ * commands must not use it as replyToMessageId.
23
+ * @internal - Exported for testing only
24
+ */
25
+ export function buildCallbackMessage(
26
+ callback: MaxCallback,
27
+ message: MaxMessage | null,
28
+ text: string = callback.payload ?? '',
29
+ ): MaxMessage & { __maxCallback: true } {
30
+ return {
31
+ __maxCallback: true,
32
+ sender: callback.user,
33
+ recipient: message ? message.recipient : { chat_id: callback.user.user_id },
34
+ timestamp: callback.timestamp,
35
+ body: {
36
+ mid: callback.callback_id,
37
+ text,
38
+ },
39
+ };
40
+ }
41
+
42
+ export async function processCallback(
43
+ callback: MaxCallback,
44
+ message: MaxMessage | null,
45
+ userLocale: string | null | undefined,
46
+ opts: MaxMonitorOptions,
47
+ ): Promise<void> {
48
+ const payload = callback.payload ?? '';
49
+ if (!payload.trim()) return;
50
+
51
+ // Presentation buttons carry private envelopes (see presentation.ts).
52
+ // Command buttons carry the command text itself and take the default path;
53
+ // plain channelData.max.buttons payloads keep arriving as message text.
54
+ const presentationCallback = decodeMaxPresentationCallback(payload);
55
+ if (presentationCallback?.kind === 'approval' || presentationCallback?.kind === 'question') {
56
+ await resolveMaxRuntimeControlCallback(presentationCallback, callback, opts);
57
+ return;
58
+ }
59
+ // Opaque callback data goes to the agent labelled, never as a slash command.
60
+ const text =
61
+ presentationCallback?.kind === 'callback'
62
+ ? `callback_data: ${presentationCallback.value}`
63
+ : payload;
64
+
65
+ await processIncomingMessage(buildCallbackMessage(callback, message, text), userLocale, opts);
66
+ }
67
+
68
+ /**
69
+ * Approvals and ask_user answers are operator actions: only senders listed
70
+ * explicitly in the account's allowFrom may press them (a wildcard is enough
71
+ * for questions, never for approvals).
72
+ */
73
+ function isMaxRuntimeControlSender(
74
+ account: ResolvedMaxAccount,
75
+ senderId: string,
76
+ kind: 'approval' | 'question',
77
+ ): boolean {
78
+ const allowFrom = (account.config.allowFrom ?? []).map((entry) =>
79
+ String(entry).trim().replace(/^max:/i, ''),
80
+ );
81
+ if (allowFrom.includes(senderId)) return true;
82
+ return kind === 'question' && allowFrom.includes('*');
83
+ }
84
+
85
+ async function resolveMaxRuntimeControlCallback(
86
+ action: Extract<MaxPresentationCallback, { kind: 'approval' | 'question' }>,
87
+ callback: MaxCallback,
88
+ opts: MaxMonitorOptions,
89
+ ): Promise<void> {
90
+ const { account, config, log } = opts;
91
+ const senderId = String(callback.user.user_id);
92
+ let notification: string;
93
+
94
+ if (!isMaxRuntimeControlSender(account, senderId, action.kind)) {
95
+ log?.warn(
96
+ `[${account.accountId}] MAX ${action.kind} button pressed by unauthorized sender ${senderId}`,
97
+ );
98
+ notification = 'You are not allowed to answer this.';
99
+ } else {
100
+ try {
101
+ if (action.kind === 'approval') {
102
+ const result = await resolveApprovalOverGateway({
103
+ cfg: config,
104
+ approvalId: action.approvalId,
105
+ approvalKind: action.approvalKind,
106
+ decision: action.decision,
107
+ channel: 'max',
108
+ accountId: account.accountId,
109
+ senderId,
110
+ });
111
+ notification = result.applied
112
+ ? `Decision recorded: ${action.decision}.`
113
+ : 'This approval was already resolved.';
114
+ } else {
115
+ const result = await questionGatewayRuntime.resolveOption({
116
+ cfg: config,
117
+ questionId: action.questionId,
118
+ optionValue: action.optionValue,
119
+ senderId,
120
+ authorize: () => isMaxRuntimeControlSender(account, senderId, 'question'),
121
+ });
122
+ notification =
123
+ result.status === 'answered'
124
+ ? 'Answer recorded.'
125
+ : result.status === 'denied'
126
+ ? 'You are not allowed to answer this.'
127
+ : 'This question is no longer open.';
128
+ }
129
+ } catch (err) {
130
+ log?.error(`[${account.accountId}] MAX ${action.kind} callback failed: ${String(err)}`);
131
+ notification = 'Could not apply this action.';
132
+ }
133
+ }
134
+
135
+ try {
136
+ await opts.api.answerCallback(callback.callback_id, { notification });
137
+ } catch (err) {
138
+ log?.debug?.(`[${account.accountId}] MAX callback answer failed: ${String(err)}`);
139
+ }
140
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Agent prompt hints of the MAX channel: stickers, location, contact, pin,
3
+ * button types and presentation for the message tool.
4
+ */
5
+
6
+ import type { ChannelPlugin } from 'openclaw/plugin-sdk/channel-core';
7
+
8
+ import type { ResolvedMaxAccount } from './accounts.js';
9
+
10
+ type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
11
+
12
+ /** message tool hints for MAX-specific actions. */
13
+ export const maxAgentPromptAdapter: NonNullable<MaxChannelPlugin['agentPrompt']> = {
14
+ messageToolHints: () => {
15
+ // Compact sticker emoji map: top 50 emojis → sticker codes
16
+ // Codes are hex IDs derived from listmax.ru external_id: parseInt(extId).toString(16)
17
+ const emojiMap =
18
+ '😂:109550b5 😊:109971b5 😍:10931eb5 🥰:10931eb5 😢:109330b5 😭:109330b5 😡:10941fb5 😱:109302b5 🤔:109308b5 👍:109368b5 👎:109323b5 ❤️:10931eb5 🔥:b4867ebb 💪:c1254bbb 🎉:10933cb5 😘:10931eb5 🤗:109b94b5 😎:10931db5 🙄:c14211bb 😴:10936eb5 😤:10941fb5 🤮:6b8bb 🤯:109302b5 😳:109302b5 🥳:10933cb5 💀:b4863ebb 🙈:b4850cbb 😏:11e4c60bb 😅:109550b5 🤣:109550b5 😋:109d2db5 😜:455b5 🤷:10d5cf5bb 😫:10936eb5 😩:10997db5 🥺:109356b5 😌:14aae3bb 😒:109323b5 🤪:455b5 😇:11e4dedbb 🙏:50cb5 💔:10997db5 👀:b48534bb ✨:11e43b2bb 😈:10941fb5 🤝:109368b5 🤦:502b5 😬:5dab4b5 🤩:5dabfb5 😶:2ae2b5';
19
+
20
+ // Also try to load full sticker-emoji-map.json for extended catalog
21
+ let extendedHint = '';
22
+ try {
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const candidates = [
26
+ path.join(__dirname, '..', 'sticker-emoji-map.json'),
27
+ path.join(process.cwd(), 'projects', 'openclaw-max', 'sticker-emoji-map.json'),
28
+ ];
29
+ for (const p of candidates) {
30
+ if (fs.existsSync(p)) {
31
+ extendedHint = ' Full emoji→sticker map available at: ' + p;
32
+ break;
33
+ }
34
+ }
35
+ } catch {}
36
+
37
+ return [
38
+ '- MAX stickers: use `message(action="sticker", target="CHAT_ID", stickerId="CODE")`. Pick a sticker code matching the mood from the emoji map below. Each entry is emoji:hexCode.',
39
+ `- Sticker emoji map: ${emojiMap}${extendedHint}`,
40
+ '- MAX location: use `message(action="sendAttachment", target="CHAT_ID", type="location", latitude="55.75", longitude="37.62")` to send a native map pin.',
41
+ '- MAX contact: use `message(action="sendAttachment", target="CHAT_ID", type="contact", contactName="Name", vcfPhone="+70001234567")` to send a native contact card.',
42
+ '- MAX pin: use `message(action="pin", target="CHAT_ID", messageId="MID")` to pin a message; `message(action="unpin", target="CHAT_ID")` to unpin.',
43
+ '- MAX buttons support types: callback (default), link, message (sends the button text as a user message — great for suggested replies), clipboard (copies payload), open_app (webApp = public name of the bot wired to the mini app), request_contact, request_geo_location. Pass via buttons=[[{"text":"...","type":"message"}]].',
44
+ '- MAX rich messages: prefer `presentation` (title/tone, text, context, divider, table/chart as monospace text, buttons and select as an inline keyboard); `pin=true` pins the sent message.',
45
+ ];
46
+ },
47
+ };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Account config and CLI setup adapters of the MAX channel: account listing
3
+ * and resolution, enable/delete, allowFrom formatting, `openclaw channels add`.
4
+ */
5
+
6
+ import type { ChannelPlugin } from 'openclaw/plugin-sdk/channel-core';
7
+ import {
8
+ applyAccountNameToChannelSection,
9
+ DEFAULT_ACCOUNT_ID,
10
+ deleteAccountFromConfigSection,
11
+ migrateBaseNameToDefaultAccount,
12
+ normalizeAccountId,
13
+ setAccountEnabledInConfigSection,
14
+ } from 'openclaw/plugin-sdk/core';
15
+
16
+ import { listMaxAccountIds, type ResolvedMaxAccount, resolveMaxAccount } from './accounts.js';
17
+
18
+ type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
19
+
20
+ /** Accounts under channels.max (top level = default account, plus channels.max.accounts). */
21
+ export const maxConfigAdapter: NonNullable<MaxChannelPlugin['config']> = {
22
+ listAccountIds: (cfg) => listMaxAccountIds(cfg),
23
+ resolveAccount: (cfg, accountId) => resolveMaxAccount({ cfg, accountId }),
24
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
25
+
26
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
27
+ setAccountEnabledInConfigSection({
28
+ cfg,
29
+ sectionKey: 'max',
30
+ accountId,
31
+ enabled,
32
+ allowTopLevel: true,
33
+ }),
34
+
35
+ deleteAccount: ({ cfg, accountId }) =>
36
+ deleteAccountFromConfigSection({
37
+ cfg,
38
+ sectionKey: 'max',
39
+ accountId,
40
+ clearBaseFields: ['botToken', 'tokenFile', 'name'],
41
+ }),
42
+
43
+ isConfigured: (account) => Boolean(account.token?.trim()),
44
+
45
+ describeAccount: (account) => ({
46
+ accountId: account.accountId,
47
+ name: account.name,
48
+ enabled: account.enabled,
49
+ configured: Boolean(account.token?.trim()),
50
+ tokenSource: account.tokenSource,
51
+ }),
52
+
53
+ resolveAllowFrom: ({ cfg, accountId }) =>
54
+ (resolveMaxAccount({ cfg, accountId }).config.allowFrom ?? []).map(String),
55
+
56
+ formatAllowFrom: ({ allowFrom }) =>
57
+ allowFrom
58
+ .map((entry) => String(entry).trim())
59
+ .filter(Boolean)
60
+ .map((entry) => entry.replace(/^max:/i, '')),
61
+ };
62
+
63
+ /** Non-interactive setup: token / token file / MAX_BOT_TOKEN for the default account. */
64
+ export const maxSetupAdapter: NonNullable<MaxChannelPlugin['setup']> = {
65
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
66
+
67
+ applyAccountName: ({ cfg, accountId, name }) =>
68
+ applyAccountNameToChannelSection({
69
+ cfg,
70
+ channelKey: 'max',
71
+ accountId,
72
+ name,
73
+ }),
74
+
75
+ validateInput: ({ accountId, input }) => {
76
+ if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
77
+ return 'MAX_BOT_TOKEN can only be used for the default account.';
78
+ }
79
+ if (!input.useEnv && !input.token && !input.tokenFile) {
80
+ return 'MAX requires --token or --token-file (or --use-env with MAX_BOT_TOKEN).';
81
+ }
82
+ return null;
83
+ },
84
+
85
+ applyAccountConfig: ({ cfg, accountId, input }) => {
86
+ const namedConfig = applyAccountNameToChannelSection({
87
+ cfg,
88
+ channelKey: 'max',
89
+ accountId,
90
+ name: input.name,
91
+ });
92
+ const next =
93
+ accountId !== DEFAULT_ACCOUNT_ID
94
+ ? migrateBaseNameToDefaultAccount({
95
+ cfg: namedConfig,
96
+ channelKey: 'max',
97
+ })
98
+ : namedConfig;
99
+
100
+ if (accountId === DEFAULT_ACCOUNT_ID) {
101
+ return {
102
+ ...next,
103
+ channels: {
104
+ ...next.channels,
105
+ max: {
106
+ ...((next.channels as Record<string, unknown>)?.max as Record<string, unknown>),
107
+ enabled: true,
108
+ ...(input.useEnv ? {} : input.token ? { botToken: input.token } : {}),
109
+ },
110
+ },
111
+ };
112
+ }
113
+
114
+ const maxSection =
115
+ ((next.channels as Record<string, unknown>)?.max as Record<string, unknown>) ?? {};
116
+ return {
117
+ ...next,
118
+ channels: {
119
+ ...next.channels,
120
+ max: {
121
+ ...maxSection,
122
+ enabled: true,
123
+ accounts: {
124
+ ...(maxSection.accounts as Record<string, unknown>),
125
+ [accountId]: {
126
+ ...((maxSection.accounts as Record<string, unknown>)?.[accountId] as Record<
127
+ string,
128
+ unknown
129
+ >),
130
+ enabled: true,
131
+ ...(input.token ? { botToken: input.token } : {}),
132
+ },
133
+ },
134
+ },
135
+ },
136
+ };
137
+ },
138
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Directory and addressing of the MAX channel: self, peers from allowFrom,
3
+ * groups from the passive chat registry, numeric target normalization.
4
+ */
5
+
6
+ import type { ChannelPlugin } from 'openclaw/plugin-sdk/channel-core';
7
+
8
+ import { type ResolvedMaxAccount, resolveMaxAccount } from './accounts.js';
9
+ import { MaxApi } from './api.js';
10
+ import { loadMaxAccountState } from './state.js';
11
+
12
+ type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
13
+
14
+ /** `user:<id>` addresses a user (DM); a bare number is a chat id. */
15
+ const MAX_TARGET_PATTERN = /^(?:user:\d+|-?\d+)$/;
16
+
17
+ function stripMaxPrefix(raw: string): string {
18
+ const trimmed = raw.trim();
19
+ return trimmed.startsWith('max:') ? trimmed.slice(4) : trimmed;
20
+ }
21
+
22
+ /**
23
+ * Targets are numeric chat ids or `user:<id>`, with an optional max: prefix.
24
+ * The `user:` prefix must survive normalization: dropping it turns a user id
25
+ * into a chat_id and MAX answers dialog.not.found.
26
+ */
27
+ export const maxMessagingAdapter: NonNullable<MaxChannelPlugin['messaging']> = {
28
+ normalizeTarget: (raw) => {
29
+ const normalized = stripMaxPrefix(raw);
30
+ return MAX_TARGET_PATTERN.test(normalized) ? normalized : undefined;
31
+ },
32
+ targetResolver: {
33
+ looksLikeId: (raw) => MAX_TARGET_PATTERN.test(stripMaxPrefix(raw)),
34
+ hint: '<chatId|user:userId>',
35
+ },
36
+ };
37
+
38
+ /** MAX has no member/chat listing for bots: peers come from allowFrom, groups from the chat registry. */
39
+ export const maxDirectoryAdapter: NonNullable<MaxChannelPlugin['directory']> = {
40
+ self: async ({ cfg, accountId }) => {
41
+ const account = resolveMaxAccount({ cfg, accountId });
42
+ if (!account.token) return null;
43
+ try {
44
+ const api = new MaxApi({ token: account.token, timeoutMs: 3000 });
45
+ const me = await api.getMe();
46
+ return {
47
+ kind: 'user' as const,
48
+ id: String(me.user_id),
49
+ name: me.first_name || undefined,
50
+ handle: me.username || undefined,
51
+ };
52
+ } catch {
53
+ return null;
54
+ }
55
+ },
56
+ listPeers: async ({ cfg, accountId }) => {
57
+ const account = resolveMaxAccount({ cfg, accountId });
58
+ // MAX doesn't expose a full user list API. Return peers from allowFrom config.
59
+ const allowFrom = account.config.allowFrom ?? [];
60
+ return allowFrom.map((id: string | number) => ({
61
+ kind: 'user' as const,
62
+ id: String(id),
63
+ name: undefined,
64
+ }));
65
+ },
66
+ listGroups: async ({ cfg, accountId }) => {
67
+ const account = resolveMaxAccount({ cfg, accountId });
68
+ if (!account.token) return [];
69
+
70
+ const groups = new Map<string, { kind: 'channel' | 'group'; id: string; name?: string }>();
71
+
72
+ // Реестр чатов пополняется событиями bot_added, chat_title_changed и сообщениями групп.
73
+ try {
74
+ const state = await loadMaxAccountState(account.accountId);
75
+ for (const entry of Object.values(state.chats ?? {})) {
76
+ if (entry.removedAt) continue;
77
+ if (entry.type !== 'chat' && entry.type !== 'channel') continue;
78
+ groups.set(String(entry.chatId), {
79
+ kind: entry.type === 'channel' ? 'channel' : 'group',
80
+ id: String(entry.chatId),
81
+ name: entry.title || undefined,
82
+ });
83
+ }
84
+ } catch {
85
+ // При недоступности реестра вернуть пустой список.
86
+ }
87
+
88
+ return [...groups.values()];
89
+ },
90
+ };
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Account lifecycle of the MAX channel: gateway start (probe, bot commands,
3
+ * monitor) and logout, status snapshots, probes and group audits (health).
4
+ */
5
+
6
+ import type { ChannelPlugin } from 'openclaw/plugin-sdk/channel-core';
7
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
8
+ import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/core';
9
+
10
+ import type { ResolvedMaxAccount } from './accounts.js';
11
+ import { MaxApi } from './api.js';
12
+ import { startMaxPolling } from './monitor.js';
13
+ import { writeMaxConfig } from './runtime.js';
14
+
15
+ type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
16
+
17
+ /** Runtime snapshots, GET /me probe and a membership audit of configured groups. */
18
+ export const maxStatusAdapter: NonNullable<MaxChannelPlugin['status']> = {
19
+ defaultRuntime: {
20
+ accountId: DEFAULT_ACCOUNT_ID,
21
+ running: false,
22
+ lastStartAt: null,
23
+ lastStopAt: null,
24
+ lastError: null,
25
+ },
26
+
27
+ buildChannelSummary: ({ snapshot }) => ({
28
+ configured: snapshot.configured ?? false,
29
+ tokenSource: snapshot.tokenSource ?? 'none',
30
+ running: snapshot.running ?? false,
31
+ // lifecycle/connected must mirror the gateway runtime store: the health cache
32
+ // compares them with the live runtime and treats a missing value as stale.
33
+ ...(snapshot.lifecycle !== undefined ? { lifecycle: snapshot.lifecycle } : {}),
34
+ ...(typeof snapshot.connected === 'boolean' ? { connected: snapshot.connected } : {}),
35
+ lastStartAt: snapshot.lastStartAt ?? null,
36
+ lastStopAt: snapshot.lastStopAt ?? null,
37
+ lastError: snapshot.lastError ?? null,
38
+ probe: snapshot.probe,
39
+ }),
40
+
41
+ probeAccount: async ({ account, timeoutMs }) => {
42
+ if (!account.token) return { ok: false, error: 'no token' };
43
+ const api = new MaxApi({ token: account.token, timeoutMs });
44
+ try {
45
+ const me = await api.getMe();
46
+ return { ok: true, bot: me };
47
+ } catch (err) {
48
+ return { ok: false, error: String(err) };
49
+ }
50
+ },
51
+
52
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
53
+ accountId: account.accountId,
54
+ name: account.name,
55
+ enabled: account.enabled,
56
+ configured: Boolean(account.token?.trim()),
57
+ tokenSource: account.tokenSource,
58
+ running: runtime?.running ?? false,
59
+ ...(runtime?.lifecycle !== undefined ? { lifecycle: runtime.lifecycle } : {}),
60
+ ...(typeof runtime?.connected === 'boolean' ? { connected: runtime.connected } : {}),
61
+ lastStartAt: runtime?.lastStartAt ?? null,
62
+ lastStopAt: runtime?.lastStopAt ?? null,
63
+ lastError: runtime?.lastError ?? null,
64
+ probe,
65
+ lastInboundAt: runtime?.lastInboundAt ?? null,
66
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
67
+ }),
68
+
69
+ auditAccount: async ({ account, timeoutMs }) => {
70
+ if (!account.token) {
71
+ return {
72
+ ok: false,
73
+ checkedGroups: 0,
74
+ unresolvedGroups: 0,
75
+ groups: [],
76
+ elapsedMs: 0,
77
+ };
78
+ }
79
+
80
+ const start = Date.now();
81
+ const groups = account.config.groups ?? {};
82
+ const groupIds = Object.keys(groups).filter((id) => id !== '*');
83
+
84
+ if (groupIds.length === 0) {
85
+ return {
86
+ ok: true,
87
+ checkedGroups: 0,
88
+ unresolvedGroups: 0,
89
+ groups: [],
90
+ elapsedMs: Date.now() - start,
91
+ };
92
+ }
93
+
94
+ const api = new MaxApi({ token: account.token, timeoutMs });
95
+ const results: Array<{
96
+ id: string;
97
+ ok: boolean;
98
+ title?: string;
99
+ error?: string;
100
+ }> = [];
101
+ let unresolvedCount = 0;
102
+
103
+ for (const groupId of groupIds) {
104
+ try {
105
+ const chat = await api.getChat(Number(groupId));
106
+ const isMember = chat.type === 'chat' || chat.type === 'channel';
107
+ if (!isMember) {
108
+ unresolvedCount++;
109
+ results.push({
110
+ id: groupId,
111
+ ok: false,
112
+ error: 'Bot is not a member of this chat',
113
+ });
114
+ } else {
115
+ // Long polling delivers group updates only to admin bots — surface
116
+ // a missing-admin state, the classic "bot is silent in the group" cause.
117
+ let adminSuffix = '';
118
+ try {
119
+ const membership = await api.getMembership(Number(groupId));
120
+ if (membership && membership.is_admin !== true) {
121
+ adminSuffix = ' (⚠ bot is not admin — group updates are not delivered via polling)';
122
+ }
123
+ } catch {
124
+ // membership endpoint unavailable — skip the admin hint
125
+ }
126
+ results.push({
127
+ id: groupId,
128
+ ok: true,
129
+ title: `${chat.title ?? ''}${adminSuffix}` || undefined,
130
+ });
131
+ }
132
+ } catch (err) {
133
+ unresolvedCount++;
134
+ results.push({
135
+ id: groupId,
136
+ ok: false,
137
+ error: String(err),
138
+ });
139
+ }
140
+ }
141
+
142
+ return {
143
+ ok: unresolvedCount === 0,
144
+ checkedGroups: groupIds.length,
145
+ unresolvedGroups: unresolvedCount,
146
+ groups: results,
147
+ elapsedMs: Date.now() - start,
148
+ };
149
+ },
150
+
151
+ collectStatusIssues: (accounts) => {
152
+ const issues: Array<{
153
+ channel: string;
154
+ accountId: string;
155
+ kind: 'config' | 'permissions' | 'auth' | 'runtime' | 'intent';
156
+ message: string;
157
+ fix?: string;
158
+ }> = [];
159
+
160
+ for (const snapshot of accounts) {
161
+ if (!snapshot.configured) {
162
+ issues.push({
163
+ channel: 'max',
164
+ accountId: snapshot.accountId,
165
+ kind: 'config' as const,
166
+ message: 'MAX bot token not configured',
167
+ fix: 'Set channels.max.botToken or MAX_BOT_TOKEN env var',
168
+ });
169
+ }
170
+ }
171
+
172
+ return issues;
173
+ },
174
+ };
175
+
176
+ /** Starts one account: probe the bot, register commands, run the monitor until abort. */
177
+ export const maxGatewayAdapter: NonNullable<MaxChannelPlugin['gateway']> = {
178
+ startAccount: async (ctx) => {
179
+ const account = ctx.account;
180
+ const token = account.token.trim();
181
+
182
+ let botLabel = '';
183
+ let botUserId: number | undefined;
184
+ let botUsername: string | undefined;
185
+ try {
186
+ const probeApi = new MaxApi({ token, timeoutMs: 3000 });
187
+ const me = await probeApi.getMe();
188
+ if (me.username) {
189
+ botLabel = ` (@${me.username})`;
190
+ botUsername = me.username;
191
+ }
192
+ botUserId = me.user_id;
193
+ } catch {
194
+ // probe failed, continue anyway
195
+ }
196
+
197
+ ctx.log?.info(`[${account.accountId}] Starting MAX provider${botLabel}`);
198
+
199
+ const api = new MaxApi({ token });
200
+
201
+ // Register bot commands if configured
202
+ const commands = ctx.cfg.channels?.max?.commands as
203
+ Array<{ name: string; description?: string }> | undefined;
204
+ if (commands?.length) {
205
+ try {
206
+ await api.setMyCommands(commands);
207
+ ctx.log?.info(`[${account.accountId}] Registered ${commands.length} bot commands`);
208
+ } catch (err) {
209
+ ctx.log?.error(`[${account.accountId}] Failed to register commands: ${String(err)}`);
210
+ }
211
+ }
212
+
213
+ return startMaxPolling({
214
+ api,
215
+ account,
216
+ config: ctx.cfg,
217
+ abortSignal: ctx.abortSignal,
218
+ botUserId,
219
+ botUsername,
220
+ log: ctx.log,
221
+ statusSink: (patch) => {
222
+ const current = ctx.getStatus();
223
+ ctx.setStatus({ ...current, ...patch });
224
+ },
225
+ });
226
+ },
227
+
228
+ logoutAccount: async ({ accountId, cfg }) => {
229
+ const nextCfg = { ...cfg } as OpenClawConfig;
230
+ const channels = { ...(nextCfg.channels as Record<string, unknown>) };
231
+ const maxSection = channels.max ? { ...(channels.max as Record<string, unknown>) } : undefined;
232
+ let cleared = false;
233
+
234
+ if (maxSection) {
235
+ if (accountId === DEFAULT_ACCOUNT_ID && maxSection.botToken) {
236
+ delete maxSection.botToken;
237
+ cleared = true;
238
+ }
239
+
240
+ const accounts = maxSection.accounts as Record<string, unknown> | undefined;
241
+ if (accounts && accountId in accounts) {
242
+ delete (accounts as Record<string, unknown>)[accountId];
243
+ cleared = true;
244
+ }
245
+
246
+ channels.max = maxSection;
247
+ nextCfg.channels = channels;
248
+
249
+ if (cleared) {
250
+ await writeMaxConfig(nextCfg);
251
+ }
252
+ }
253
+
254
+ return { cleared, loggedOut: cleared };
255
+ },
256
+ };