@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,38 @@
1
+ /**
2
+ * Agent prompt hints of the MAX channel: stickers, location, contact, pin,
3
+ * button types and presentation for the message tool.
4
+ */
5
+ /** message tool hints for MAX-specific actions. */
6
+ export const maxAgentPromptAdapter = {
7
+ messageToolHints: () => {
8
+ // Compact sticker emoji map: top 50 emojis → sticker codes
9
+ // Codes are hex IDs derived from listmax.ru external_id: parseInt(extId).toString(16)
10
+ const emojiMap = '😂: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';
11
+ // Also try to load full sticker-emoji-map.json for extended catalog
12
+ let extendedHint = '';
13
+ try {
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const candidates = [
17
+ path.join(__dirname, '..', 'sticker-emoji-map.json'),
18
+ path.join(process.cwd(), 'projects', 'openclaw-max', 'sticker-emoji-map.json'),
19
+ ];
20
+ for (const p of candidates) {
21
+ if (fs.existsSync(p)) {
22
+ extendedHint = ' Full emoji→sticker map available at: ' + p;
23
+ break;
24
+ }
25
+ }
26
+ }
27
+ catch { }
28
+ return [
29
+ '- 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.',
30
+ `- Sticker emoji map: ${emojiMap}${extendedHint}`,
31
+ '- MAX location: use `message(action="sendAttachment", target="CHAT_ID", type="location", latitude="55.75", longitude="37.62")` to send a native map pin.',
32
+ '- MAX contact: use `message(action="sendAttachment", target="CHAT_ID", type="contact", contactName="Name", vcfPhone="+70001234567")` to send a native contact card.',
33
+ '- MAX pin: use `message(action="pin", target="CHAT_ID", messageId="MID")` to pin a message; `message(action="unpin", target="CHAT_ID")` to unpin.',
34
+ '- 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"}]].',
35
+ '- 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.',
36
+ ];
37
+ },
38
+ };
@@ -0,0 +1,103 @@
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
+ import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, migrateBaseNameToDefaultAccount, normalizeAccountId, setAccountEnabledInConfigSection, } from 'openclaw/plugin-sdk/core';
6
+ import { listMaxAccountIds, resolveMaxAccount } from './accounts.js';
7
+ /** Accounts under channels.max (top level = default account, plus channels.max.accounts). */
8
+ export const maxConfigAdapter = {
9
+ listAccountIds: (cfg) => listMaxAccountIds(cfg),
10
+ resolveAccount: (cfg, accountId) => resolveMaxAccount({ cfg, accountId }),
11
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
12
+ setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({
13
+ cfg,
14
+ sectionKey: 'max',
15
+ accountId,
16
+ enabled,
17
+ allowTopLevel: true,
18
+ }),
19
+ deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfigSection({
20
+ cfg,
21
+ sectionKey: 'max',
22
+ accountId,
23
+ clearBaseFields: ['botToken', 'tokenFile', 'name'],
24
+ }),
25
+ isConfigured: (account) => Boolean(account.token?.trim()),
26
+ describeAccount: (account) => ({
27
+ accountId: account.accountId,
28
+ name: account.name,
29
+ enabled: account.enabled,
30
+ configured: Boolean(account.token?.trim()),
31
+ tokenSource: account.tokenSource,
32
+ }),
33
+ resolveAllowFrom: ({ cfg, accountId }) => (resolveMaxAccount({ cfg, accountId }).config.allowFrom ?? []).map(String),
34
+ formatAllowFrom: ({ allowFrom }) => allowFrom
35
+ .map((entry) => String(entry).trim())
36
+ .filter(Boolean)
37
+ .map((entry) => entry.replace(/^max:/i, '')),
38
+ };
39
+ /** Non-interactive setup: token / token file / MAX_BOT_TOKEN for the default account. */
40
+ export const maxSetupAdapter = {
41
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
42
+ applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({
43
+ cfg,
44
+ channelKey: 'max',
45
+ accountId,
46
+ name,
47
+ }),
48
+ validateInput: ({ accountId, input }) => {
49
+ if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
50
+ return 'MAX_BOT_TOKEN can only be used for the default account.';
51
+ }
52
+ if (!input.useEnv && !input.token && !input.tokenFile) {
53
+ return 'MAX requires --token or --token-file (or --use-env with MAX_BOT_TOKEN).';
54
+ }
55
+ return null;
56
+ },
57
+ applyAccountConfig: ({ cfg, accountId, input }) => {
58
+ const namedConfig = applyAccountNameToChannelSection({
59
+ cfg,
60
+ channelKey: 'max',
61
+ accountId,
62
+ name: input.name,
63
+ });
64
+ const next = accountId !== DEFAULT_ACCOUNT_ID
65
+ ? migrateBaseNameToDefaultAccount({
66
+ cfg: namedConfig,
67
+ channelKey: 'max',
68
+ })
69
+ : namedConfig;
70
+ if (accountId === DEFAULT_ACCOUNT_ID) {
71
+ return {
72
+ ...next,
73
+ channels: {
74
+ ...next.channels,
75
+ max: {
76
+ ...next.channels?.max,
77
+ enabled: true,
78
+ ...(input.useEnv ? {} : input.token ? { botToken: input.token } : {}),
79
+ },
80
+ },
81
+ };
82
+ }
83
+ const maxSection = next.channels?.max ?? {};
84
+ return {
85
+ ...next,
86
+ channels: {
87
+ ...next.channels,
88
+ max: {
89
+ ...maxSection,
90
+ enabled: true,
91
+ accounts: {
92
+ ...maxSection.accounts,
93
+ [accountId]: {
94
+ ...maxSection.accounts?.[accountId],
95
+ enabled: true,
96
+ ...(input.token ? { botToken: input.token } : {}),
97
+ },
98
+ },
99
+ },
100
+ },
101
+ };
102
+ },
103
+ };
@@ -0,0 +1,84 @@
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
+ import { resolveMaxAccount } from './accounts.js';
6
+ import { MaxApi } from './api.js';
7
+ import { loadMaxAccountState } from './state.js';
8
+ /** `user:<id>` addresses a user (DM); a bare number is a chat id. */
9
+ const MAX_TARGET_PATTERN = /^(?:user:\d+|-?\d+)$/;
10
+ function stripMaxPrefix(raw) {
11
+ const trimmed = raw.trim();
12
+ return trimmed.startsWith('max:') ? trimmed.slice(4) : trimmed;
13
+ }
14
+ /**
15
+ * Targets are numeric chat ids or `user:<id>`, with an optional max: prefix.
16
+ * The `user:` prefix must survive normalization: dropping it turns a user id
17
+ * into a chat_id and MAX answers dialog.not.found.
18
+ */
19
+ export const maxMessagingAdapter = {
20
+ normalizeTarget: (raw) => {
21
+ const normalized = stripMaxPrefix(raw);
22
+ return MAX_TARGET_PATTERN.test(normalized) ? normalized : undefined;
23
+ },
24
+ targetResolver: {
25
+ looksLikeId: (raw) => MAX_TARGET_PATTERN.test(stripMaxPrefix(raw)),
26
+ hint: '<chatId|user:userId>',
27
+ },
28
+ };
29
+ /** MAX has no member/chat listing for bots: peers come from allowFrom, groups from the chat registry. */
30
+ export const maxDirectoryAdapter = {
31
+ self: async ({ cfg, accountId }) => {
32
+ const account = resolveMaxAccount({ cfg, accountId });
33
+ if (!account.token)
34
+ return null;
35
+ try {
36
+ const api = new MaxApi({ token: account.token, timeoutMs: 3000 });
37
+ const me = await api.getMe();
38
+ return {
39
+ kind: 'user',
40
+ id: String(me.user_id),
41
+ name: me.first_name || undefined,
42
+ handle: me.username || undefined,
43
+ };
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ },
49
+ listPeers: async ({ cfg, accountId }) => {
50
+ const account = resolveMaxAccount({ cfg, accountId });
51
+ // MAX doesn't expose a full user list API. Return peers from allowFrom config.
52
+ const allowFrom = account.config.allowFrom ?? [];
53
+ return allowFrom.map((id) => ({
54
+ kind: 'user',
55
+ id: String(id),
56
+ name: undefined,
57
+ }));
58
+ },
59
+ listGroups: async ({ cfg, accountId }) => {
60
+ const account = resolveMaxAccount({ cfg, accountId });
61
+ if (!account.token)
62
+ return [];
63
+ const groups = new Map();
64
+ // РÐĩÐĩҁ҂Ҁ Ņ‡Ð°Ņ‚ÐūÐē ÐŋÐūÐŋÐūÐŧÐ―ŅÐĩŅ‚ŅŅ ҁÐūÐąŅ‹Ņ‚ÐļŅÐžÐļ bot_added, chat_title_changed Ðļ ҁÐūÐūÐąŅ‰ÐĩÐ―ÐļŅÐžÐļ ÐģŅ€ŅƒÐŋÐŋ.
65
+ try {
66
+ const state = await loadMaxAccountState(account.accountId);
67
+ for (const entry of Object.values(state.chats ?? {})) {
68
+ if (entry.removedAt)
69
+ continue;
70
+ if (entry.type !== 'chat' && entry.type !== 'channel')
71
+ continue;
72
+ groups.set(String(entry.chatId), {
73
+ kind: entry.type === 'channel' ? 'channel' : 'group',
74
+ id: String(entry.chatId),
75
+ name: entry.title || undefined,
76
+ });
77
+ }
78
+ }
79
+ catch {
80
+ // ÐŸŅ€Ðļ Ð―ÐĩÐīÐūŅŅ‚ŅƒÐŋÐ―Ðūҁ҂Ðļ Ņ€ÐĩÐĩŅŅ‚Ņ€Ð° ÐēÐĩŅ€Ð―ŅƒŅ‚ŅŒ ÐŋŅƒŅŅ‚ÐūÐđ ҁÐŋÐļҁÐūК.
81
+ }
82
+ return [...groups.values()];
83
+ },
84
+ };
@@ -0,0 +1,219 @@
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
+ import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/core';
6
+ import { MaxApi } from './api.js';
7
+ import { startMaxPolling } from './monitor.js';
8
+ import { writeMaxConfig } from './runtime.js';
9
+ /** Runtime snapshots, GET /me probe and a membership audit of configured groups. */
10
+ export const maxStatusAdapter = {
11
+ defaultRuntime: {
12
+ accountId: DEFAULT_ACCOUNT_ID,
13
+ running: false,
14
+ lastStartAt: null,
15
+ lastStopAt: null,
16
+ lastError: null,
17
+ },
18
+ buildChannelSummary: ({ snapshot }) => ({
19
+ configured: snapshot.configured ?? false,
20
+ tokenSource: snapshot.tokenSource ?? 'none',
21
+ running: snapshot.running ?? false,
22
+ // lifecycle/connected must mirror the gateway runtime store: the health cache
23
+ // compares them with the live runtime and treats a missing value as stale.
24
+ ...(snapshot.lifecycle !== undefined ? { lifecycle: snapshot.lifecycle } : {}),
25
+ ...(typeof snapshot.connected === 'boolean' ? { connected: snapshot.connected } : {}),
26
+ lastStartAt: snapshot.lastStartAt ?? null,
27
+ lastStopAt: snapshot.lastStopAt ?? null,
28
+ lastError: snapshot.lastError ?? null,
29
+ probe: snapshot.probe,
30
+ }),
31
+ probeAccount: async ({ account, timeoutMs }) => {
32
+ if (!account.token)
33
+ return { ok: false, error: 'no token' };
34
+ const api = new MaxApi({ token: account.token, timeoutMs });
35
+ try {
36
+ const me = await api.getMe();
37
+ return { ok: true, bot: me };
38
+ }
39
+ catch (err) {
40
+ return { ok: false, error: String(err) };
41
+ }
42
+ },
43
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
44
+ accountId: account.accountId,
45
+ name: account.name,
46
+ enabled: account.enabled,
47
+ configured: Boolean(account.token?.trim()),
48
+ tokenSource: account.tokenSource,
49
+ running: runtime?.running ?? false,
50
+ ...(runtime?.lifecycle !== undefined ? { lifecycle: runtime.lifecycle } : {}),
51
+ ...(typeof runtime?.connected === 'boolean' ? { connected: runtime.connected } : {}),
52
+ lastStartAt: runtime?.lastStartAt ?? null,
53
+ lastStopAt: runtime?.lastStopAt ?? null,
54
+ lastError: runtime?.lastError ?? null,
55
+ probe,
56
+ lastInboundAt: runtime?.lastInboundAt ?? null,
57
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
58
+ }),
59
+ auditAccount: async ({ account, timeoutMs }) => {
60
+ if (!account.token) {
61
+ return {
62
+ ok: false,
63
+ checkedGroups: 0,
64
+ unresolvedGroups: 0,
65
+ groups: [],
66
+ elapsedMs: 0,
67
+ };
68
+ }
69
+ const start = Date.now();
70
+ const groups = account.config.groups ?? {};
71
+ const groupIds = Object.keys(groups).filter((id) => id !== '*');
72
+ if (groupIds.length === 0) {
73
+ return {
74
+ ok: true,
75
+ checkedGroups: 0,
76
+ unresolvedGroups: 0,
77
+ groups: [],
78
+ elapsedMs: Date.now() - start,
79
+ };
80
+ }
81
+ const api = new MaxApi({ token: account.token, timeoutMs });
82
+ const results = [];
83
+ let unresolvedCount = 0;
84
+ for (const groupId of groupIds) {
85
+ try {
86
+ const chat = await api.getChat(Number(groupId));
87
+ const isMember = chat.type === 'chat' || chat.type === 'channel';
88
+ if (!isMember) {
89
+ unresolvedCount++;
90
+ results.push({
91
+ id: groupId,
92
+ ok: false,
93
+ error: 'Bot is not a member of this chat',
94
+ });
95
+ }
96
+ else {
97
+ // Long polling delivers group updates only to admin bots — surface
98
+ // a missing-admin state, the classic "bot is silent in the group" cause.
99
+ let adminSuffix = '';
100
+ try {
101
+ const membership = await api.getMembership(Number(groupId));
102
+ if (membership && membership.is_admin !== true) {
103
+ adminSuffix = ' (⚠ bot is not admin — group updates are not delivered via polling)';
104
+ }
105
+ }
106
+ catch {
107
+ // membership endpoint unavailable — skip the admin hint
108
+ }
109
+ results.push({
110
+ id: groupId,
111
+ ok: true,
112
+ title: `${chat.title ?? ''}${adminSuffix}` || undefined,
113
+ });
114
+ }
115
+ }
116
+ catch (err) {
117
+ unresolvedCount++;
118
+ results.push({
119
+ id: groupId,
120
+ ok: false,
121
+ error: String(err),
122
+ });
123
+ }
124
+ }
125
+ return {
126
+ ok: unresolvedCount === 0,
127
+ checkedGroups: groupIds.length,
128
+ unresolvedGroups: unresolvedCount,
129
+ groups: results,
130
+ elapsedMs: Date.now() - start,
131
+ };
132
+ },
133
+ collectStatusIssues: (accounts) => {
134
+ const issues = [];
135
+ for (const snapshot of accounts) {
136
+ if (!snapshot.configured) {
137
+ issues.push({
138
+ channel: 'max',
139
+ accountId: snapshot.accountId,
140
+ kind: 'config',
141
+ message: 'MAX bot token not configured',
142
+ fix: 'Set channels.max.botToken or MAX_BOT_TOKEN env var',
143
+ });
144
+ }
145
+ }
146
+ return issues;
147
+ },
148
+ };
149
+ /** Starts one account: probe the bot, register commands, run the monitor until abort. */
150
+ export const maxGatewayAdapter = {
151
+ startAccount: async (ctx) => {
152
+ const account = ctx.account;
153
+ const token = account.token.trim();
154
+ let botLabel = '';
155
+ let botUserId;
156
+ let botUsername;
157
+ try {
158
+ const probeApi = new MaxApi({ token, timeoutMs: 3000 });
159
+ const me = await probeApi.getMe();
160
+ if (me.username) {
161
+ botLabel = ` (@${me.username})`;
162
+ botUsername = me.username;
163
+ }
164
+ botUserId = me.user_id;
165
+ }
166
+ catch {
167
+ // probe failed, continue anyway
168
+ }
169
+ ctx.log?.info(`[${account.accountId}] Starting MAX provider${botLabel}`);
170
+ const api = new MaxApi({ token });
171
+ // Register bot commands if configured
172
+ const commands = ctx.cfg.channels?.max?.commands;
173
+ if (commands?.length) {
174
+ try {
175
+ await api.setMyCommands(commands);
176
+ ctx.log?.info(`[${account.accountId}] Registered ${commands.length} bot commands`);
177
+ }
178
+ catch (err) {
179
+ ctx.log?.error(`[${account.accountId}] Failed to register commands: ${String(err)}`);
180
+ }
181
+ }
182
+ return startMaxPolling({
183
+ api,
184
+ account,
185
+ config: ctx.cfg,
186
+ abortSignal: ctx.abortSignal,
187
+ botUserId,
188
+ botUsername,
189
+ log: ctx.log,
190
+ statusSink: (patch) => {
191
+ const current = ctx.getStatus();
192
+ ctx.setStatus({ ...current, ...patch });
193
+ },
194
+ });
195
+ },
196
+ logoutAccount: async ({ accountId, cfg }) => {
197
+ const nextCfg = { ...cfg };
198
+ const channels = { ...nextCfg.channels };
199
+ const maxSection = channels.max ? { ...channels.max } : undefined;
200
+ let cleared = false;
201
+ if (maxSection) {
202
+ if (accountId === DEFAULT_ACCOUNT_ID && maxSection.botToken) {
203
+ delete maxSection.botToken;
204
+ cleared = true;
205
+ }
206
+ const accounts = maxSection.accounts;
207
+ if (accounts && accountId in accounts) {
208
+ delete accounts[accountId];
209
+ cleared = true;
210
+ }
211
+ channels.max = maxSection;
212
+ nextCfg.channels = channels;
213
+ if (cleared) {
214
+ await writeMaxConfig(nextCfg);
215
+ }
216
+ }
217
+ return { cleared, loggedOut: cleared };
218
+ },
219
+ };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Outbound adapter of the MAX channel: sendText / sendPayload / sendMedia,
3
+ * presentation rendering and delivery pins for core's outbound path.
4
+ */
5
+ import { resolveMaxAccount } from './accounts.js';
6
+ import { materializeMaxPresentation, MAX_PRESENTATION_CAPABILITIES, MAX_TEXT_LIMIT, renderMaxPresentation, } from './presentation.js';
7
+ import { getMaxRuntime, loadMaxConfig } from './runtime.js';
8
+ import { pinMaxMessage, readMaxChannelButtons, sendMaxMediaGroup, sendMaxMediaMessage, sendMaxMessage, } from './send.js';
9
+ /** Direct delivery with 4000-character markdown chunks. */
10
+ export const maxOutboundAdapter = {
11
+ deliveryMode: 'direct',
12
+ chunker: (text, limit) => getMaxRuntime().channel.text.chunkMarkdownText(text, limit),
13
+ chunkerMode: 'markdown',
14
+ textChunkLimit: 4000,
15
+ // Presentation (docs/plugins/message-presentation.md): core adapts to these
16
+ // capabilities, renders through renderPresentation and pins through
17
+ // pinDeliveredMessage after the first delivered message.
18
+ presentationCapabilities: MAX_PRESENTATION_CAPABILITIES,
19
+ deliveryCapabilities: { pin: true },
20
+ renderPresentation: ({ payload, presentation }) => renderMaxPresentation(payload, presentation),
21
+ pinDeliveredMessage: async ({ cfg, target, messageId, pin, assertDirectAdapterHandoff }) => {
22
+ const account = resolveMaxAccount({ cfg, accountId: target.accountId });
23
+ if (!account.token)
24
+ throw new Error('MAX bot token not configured');
25
+ assertDirectAdapterHandoff?.();
26
+ // Dialogs cannot pin: pinMaxMessage resolves { pinned: false } without an
27
+ // API call or error, so core logs no failed pin for a DM delivery.
28
+ await pinMaxMessage(target.to, messageId, {
29
+ token: account.token,
30
+ pinNotify: pin.notify === true,
31
+ beforeRequest: assertDirectAdapterHandoff,
32
+ });
33
+ },
34
+ sendPayload: async ({ to, text, payload: rawPayload, mediaUrl, accountId, replyToId }) => {
35
+ const cfg = await loadMaxConfig();
36
+ const account = resolveMaxAccount({ cfg, accountId });
37
+ if (!account.token)
38
+ throw new Error('MAX bot token not configured');
39
+ // Core renders presentation before sendPayload; a payload that still
40
+ // carries one came through a path that did not, so apply the same policy.
41
+ const payload = rawPayload.presentation
42
+ ? await materializeMaxPresentation(rawPayload)
43
+ : rawPayload;
44
+ const effectiveText = payload === rawPayload ? text : (payload.text ?? '');
45
+ const buttons = readMaxChannelButtons(payload.channelData);
46
+ const mediaUrls = mediaUrl
47
+ ? [mediaUrl]
48
+ : payload.mediaUrls?.length
49
+ ? payload.mediaUrls
50
+ : payload.mediaUrl
51
+ ? [payload.mediaUrl]
52
+ : [];
53
+ if (mediaUrls.length) {
54
+ // Albums: up to 12 images/videos per message, caption on the first,
55
+ // buttons on the last; report the first message (delivery.pin).
56
+ const result = await sendMaxMediaGroup(to, effectiveText, mediaUrls, {
57
+ token: account.token,
58
+ replyToMessageId: replyToId ?? undefined,
59
+ format: 'markdown',
60
+ buttons,
61
+ mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
62
+ });
63
+ return {
64
+ channel: 'max',
65
+ messageId: result.messageIds[0] ?? '',
66
+ };
67
+ }
68
+ // MAX caps a message at 4000 characters: split, keyboard on the last
69
+ // chunk, report the first one (delivery.pin pins the first chunk).
70
+ const chunks = effectiveText.length > MAX_TEXT_LIMIT
71
+ ? getMaxRuntime().channel.text.chunkMarkdownText(effectiveText, MAX_TEXT_LIMIT)
72
+ : [effectiveText];
73
+ let firstMessageId = '';
74
+ for (let index = 0; index < chunks.length; index += 1) {
75
+ const result = await sendMaxMessage(to, chunks[index], {
76
+ token: account.token,
77
+ replyToMessageId: index === 0 ? (replyToId ?? undefined) : undefined,
78
+ format: 'markdown',
79
+ buttons: index === chunks.length - 1 ? buttons : undefined,
80
+ });
81
+ if (index === 0)
82
+ firstMessageId = result.messageId;
83
+ }
84
+ return {
85
+ channel: 'max',
86
+ messageId: firstMessageId,
87
+ };
88
+ },
89
+ sendText: async ({ to, text, accountId, replyToId }) => {
90
+ const cfg = await loadMaxConfig();
91
+ const account = resolveMaxAccount({ cfg, accountId });
92
+ if (!account.token)
93
+ throw new Error('MAX bot token not configured');
94
+ const result = await sendMaxMessage(to, text, {
95
+ token: account.token,
96
+ replyToMessageId: replyToId ?? undefined,
97
+ format: 'markdown',
98
+ });
99
+ return {
100
+ channel: 'max',
101
+ messageId: result.messageId,
102
+ };
103
+ },
104
+ sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
105
+ const cfg = await loadMaxConfig();
106
+ const account = resolveMaxAccount({ cfg, accountId });
107
+ if (!account.token)
108
+ throw new Error('MAX bot token not configured');
109
+ if (!mediaUrl) {
110
+ // No media, send as text
111
+ const result = await sendMaxMessage(to, text, {
112
+ token: account.token,
113
+ replyToMessageId: replyToId ?? undefined,
114
+ format: 'markdown',
115
+ });
116
+ return {
117
+ channel: 'max',
118
+ messageId: result.messageId,
119
+ };
120
+ }
121
+ // Upload and send media
122
+ const result = await sendMaxMediaMessage(to, text, mediaUrl, {
123
+ token: account.token,
124
+ replyToMessageId: replyToId ?? undefined,
125
+ format: 'markdown',
126
+ mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
127
+ });
128
+ return {
129
+ channel: 'max',
130
+ messageId: result.messageId,
131
+ };
132
+ },
133
+ };