@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,148 @@
1
+ /**
2
+ * MAX account resolution — reads config and produces a resolved account object.
3
+ */
4
+ import { lstatSync, readFileSync } from 'node:fs';
5
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from 'openclaw/plugin-sdk/core';
6
+ /**
7
+ * Get the MAX channel section from config.
8
+ */
9
+ function getMaxSection(cfg) {
10
+ return cfg.channels?.max;
11
+ }
12
+ /** Read a secret from a regular (non-symlink) file; "" when missing or unreadable. */
13
+ export function readSecretFile(filePath) {
14
+ return readTokenFile(filePath);
15
+ }
16
+ function readTokenFile(tokenFile) {
17
+ const filePath = tokenFile?.trim();
18
+ if (!filePath)
19
+ return '';
20
+ try {
21
+ const stat = lstatSync(filePath);
22
+ if (!stat.isFile() || stat.isSymbolicLink())
23
+ return '';
24
+ return readFileSync(filePath, 'utf8').trim();
25
+ }
26
+ catch {
27
+ return '';
28
+ }
29
+ }
30
+ /**
31
+ * List all MAX account IDs from config.
32
+ */
33
+ export function listMaxAccountIds(cfg) {
34
+ const section = getMaxSection(cfg);
35
+ if (!section)
36
+ return [];
37
+ const ids = [];
38
+ // Check for default account (top-level botToken)
39
+ const hasDefault = section.botToken || section.tokenFile || process.env.MAX_BOT_TOKEN;
40
+ if (hasDefault)
41
+ ids.push(DEFAULT_ACCOUNT_ID);
42
+ // Check for named accounts
43
+ const accounts = section.accounts;
44
+ if (accounts) {
45
+ for (const key of Object.keys(accounts)) {
46
+ const normalized = normalizeAccountId(key);
47
+ if (normalized !== DEFAULT_ACCOUNT_ID && !ids.includes(normalized)) {
48
+ ids.push(normalized);
49
+ }
50
+ }
51
+ }
52
+ // If section exists but no token sources found, still return default
53
+ if (ids.length === 0 && section.enabled !== false) {
54
+ ids.push(DEFAULT_ACCOUNT_ID);
55
+ }
56
+ return ids;
57
+ }
58
+ /**
59
+ * Resolve the default account ID.
60
+ */
61
+ export function resolveDefaultMaxAccountId(_cfg) {
62
+ return DEFAULT_ACCOUNT_ID;
63
+ }
64
+ /**
65
+ * Resolve a single MAX account from config.
66
+ */
67
+ export function resolveMaxAccount(params) {
68
+ const { cfg, accountId: rawId } = params;
69
+ const accountId = rawId ? normalizeAccountId(rawId) : DEFAULT_ACCOUNT_ID;
70
+ const section = getMaxSection(cfg) ?? {};
71
+ const accounts = section.accounts;
72
+ let accountConfig;
73
+ let token = '';
74
+ let tokenSource = 'none';
75
+ if (accountId === DEFAULT_ACCOUNT_ID) {
76
+ // Default account: top-level config
77
+ accountConfig = {
78
+ enabled: section.enabled !== false,
79
+ botToken: section.botToken,
80
+ tokenFile: section.tokenFile,
81
+ name: section.name,
82
+ dmPolicy: section.dmPolicy,
83
+ allowFrom: section.allowFrom,
84
+ groups: section.groups,
85
+ groupPolicy: section.groupPolicy,
86
+ groupAllowFrom: section.groupAllowFrom,
87
+ transport: section.transport,
88
+ webhookUrl: section.webhookUrl,
89
+ webhookSecret: section.webhookSecret,
90
+ webhookSecretFile: section.webhookSecretFile,
91
+ webhookPath: section.webhookPath,
92
+ mediaMaxMb: section.mediaMaxMb,
93
+ streamMode: section.streamMode,
94
+ markSeen: section.markSeen,
95
+ };
96
+ if (accountConfig.botToken?.trim()) {
97
+ token = accountConfig.botToken.trim();
98
+ tokenSource = 'config';
99
+ }
100
+ else if (accountConfig.tokenFile?.trim()) {
101
+ token = readTokenFile(accountConfig.tokenFile);
102
+ tokenSource = token ? 'file' : 'none';
103
+ }
104
+ if (!token && process.env.MAX_BOT_TOKEN?.trim()) {
105
+ token = process.env.MAX_BOT_TOKEN.trim();
106
+ tokenSource = 'env';
107
+ }
108
+ }
109
+ else {
110
+ // Named account
111
+ const raw = accounts?.[accountId] ?? {};
112
+ accountConfig = {
113
+ enabled: raw.enabled !== false,
114
+ botToken: raw.botToken,
115
+ tokenFile: raw.tokenFile,
116
+ name: raw.name,
117
+ dmPolicy: raw.dmPolicy,
118
+ allowFrom: raw.allowFrom,
119
+ groups: raw.groups,
120
+ groupPolicy: raw.groupPolicy,
121
+ groupAllowFrom: raw.groupAllowFrom,
122
+ transport: raw.transport,
123
+ webhookUrl: raw.webhookUrl,
124
+ webhookSecret: raw.webhookSecret,
125
+ webhookSecretFile: raw.webhookSecretFile,
126
+ webhookPath: raw.webhookPath,
127
+ mediaMaxMb: raw.mediaMaxMb,
128
+ streamMode: raw.streamMode,
129
+ markSeen: raw.markSeen,
130
+ };
131
+ if (accountConfig.botToken?.trim()) {
132
+ token = accountConfig.botToken.trim();
133
+ tokenSource = 'config';
134
+ }
135
+ else if (accountConfig.tokenFile?.trim()) {
136
+ token = readTokenFile(accountConfig.tokenFile);
137
+ tokenSource = token ? 'file' : 'none';
138
+ }
139
+ }
140
+ return {
141
+ accountId,
142
+ name: accountConfig.name,
143
+ enabled: accountConfig.enabled ?? true,
144
+ token,
145
+ tokenSource,
146
+ config: accountConfig,
147
+ };
148
+ }
@@ -0,0 +1,421 @@
1
+ /**
2
+ * MAX channel message actions adapter — implements message tool actions
3
+ */
4
+ import { normalizeMessagePresentation } from 'openclaw/plugin-sdk/interactive-runtime';
5
+ import { readStringParam } from 'openclaw/plugin-sdk/param-readers';
6
+ import { jsonResult } from 'openclaw/plugin-sdk/tool-results';
7
+ import { listMaxAccountIds, resolveMaxAccount } from './accounts.js';
8
+ import { materializeMaxPresentation, MAX_TEXT_LIMIT, readMaxDeliveryPin } from './presentation.js';
9
+ import { getMaxRuntime } from './runtime.js';
10
+ import { deleteMaxMessage, editMaxMessage, pinMaxMessage, readMaxChannelButtons, readMaxSendButtons, sendMaxContact, sendMaxLocation, sendMaxMediaGroup, sendMaxMediaMessage, sendMaxMessage, sendMaxSticker, unpinMaxMessage, } from './send.js';
11
+ import { getLastStickerCode } from './sticker-cache.js';
12
+ const providerId = 'max';
13
+ const mediaSourceKeys = ['media', 'filePath', 'path', 'fileUrl', 'url', 'buffer', 'image'];
14
+ function listEnabledAccounts(cfg) {
15
+ return listMaxAccountIds(cfg)
16
+ .map((accountId) => resolveMaxAccount({ cfg, accountId }))
17
+ .filter((account) => account.enabled && account.token);
18
+ }
19
+ function readTargetParam(params, required = true) {
20
+ return (readStringParam(params, 'to') ??
21
+ readStringParam(params, 'target') ??
22
+ readStringParam(params, 'chatId') ??
23
+ readStringParam(params, 'channelId', { required }));
24
+ }
25
+ /**
26
+ * Media sources in order: the direct media field first, then every
27
+ * structured `attachments[]` item (one source per item). Duplicates dropped.
28
+ */
29
+ function readMediaSources(params) {
30
+ const sources = [];
31
+ const add = (value) => {
32
+ if (value && !sources.includes(value))
33
+ sources.push(value);
34
+ };
35
+ for (const key of mediaSourceKeys) {
36
+ const value = readStringParam(params, key, { trim: false });
37
+ if (value) {
38
+ add(value);
39
+ break;
40
+ }
41
+ }
42
+ if (Array.isArray(params.attachments)) {
43
+ for (const item of params.attachments) {
44
+ if (!item || typeof item !== 'object' || Array.isArray(item))
45
+ continue;
46
+ const attachment = item;
47
+ for (const key of mediaSourceKeys) {
48
+ const value = typeof attachment[key] === 'string' ? attachment[key] : undefined;
49
+ if (value) {
50
+ add(value);
51
+ break;
52
+ }
53
+ }
54
+ }
55
+ }
56
+ return sources;
57
+ }
58
+ /**
59
+ * Send one or more media sources: one goes as a single message, several as
60
+ * albums (up to 12 images/videos per message, audio/files one by one, order
61
+ * kept). Returns every sent message id; the first one carries the caption.
62
+ */
63
+ async function sendMaxMediaSources(to, caption, sources, opts) {
64
+ if (sources.length === 1) {
65
+ const result = await sendMaxMediaMessage(to, caption, sources[0], opts);
66
+ return {
67
+ messageId: result.messageId,
68
+ messageIds: result.messageId ? [result.messageId] : [],
69
+ chatType: result.raw.message?.recipient?.chat_type,
70
+ };
71
+ }
72
+ // A failed group must not hide the ones already sent (a retry by the agent
73
+ // would duplicate them): report partial failures, throw only when none went.
74
+ const errors = [];
75
+ let firstError;
76
+ const { messageIds } = await sendMaxMediaGroup(to, caption, sources, {
77
+ ...opts,
78
+ onError: (err, failed) => {
79
+ firstError ??= err;
80
+ errors.push(`${failed.join(', ')}: ${String(err)}`);
81
+ },
82
+ });
83
+ if (messageIds.length === 0 && firstError !== undefined)
84
+ throw firstError;
85
+ return {
86
+ messageId: messageIds[0] ?? '',
87
+ messageIds,
88
+ ...(errors.length ? { mediaErrors: errors } : {}),
89
+ };
90
+ }
91
+ /** messageIds / mediaErrors for a multi-message media send. */
92
+ function mediaResultExtra(result) {
93
+ return {
94
+ ...(result.messageIds.length > 1 ? { messageIds: result.messageIds } : {}),
95
+ ...(result.mediaErrors ? { mediaErrors: result.mediaErrors } : {}),
96
+ };
97
+ }
98
+ /**
99
+ * Coordinates from `latitude`+`longitude` (both required) or a
100
+ * `location: "LAT,LNG"` / "LAT LNG" string; undefined when absent or not
101
+ * numeric.
102
+ */
103
+ function readLocationParams(params) {
104
+ let lat;
105
+ let lng;
106
+ if (params.latitude != null && params.longitude != null) {
107
+ lat = parseFloat(String(params.latitude));
108
+ lng = parseFloat(String(params.longitude));
109
+ }
110
+ else {
111
+ const locationStr = readStringParam(params, 'location');
112
+ const m = locationStr?.match(/(-?\d+\.?\d*)[,\s]+(-?\d+\.?\d*)/);
113
+ if (m) {
114
+ lat = parseFloat(m[1]);
115
+ lng = parseFloat(m[2]);
116
+ }
117
+ }
118
+ if (lat == null || lng == null || isNaN(lat) || isNaN(lng))
119
+ return undefined;
120
+ return { latitude: lat, longitude: lng };
121
+ }
122
+ /** Contact card fields: contactId (MAX user id) and vcfPhone/phone next to the given name. */
123
+ function readContactParams(params, name) {
124
+ const contactId = params.contactId != null ? Number(params.contactId) : undefined;
125
+ const vcfPhone = readStringParam(params, 'vcfPhone') ?? readStringParam(params, 'phone');
126
+ return {
127
+ name,
128
+ contactId: contactId && !isNaN(contactId) ? contactId : undefined,
129
+ vcfPhone: vcfPhone ?? undefined,
130
+ };
131
+ }
132
+ export const maxMessageActions = {
133
+ describeMessageTool: ({ cfg }) => {
134
+ const accounts = listEnabledAccounts(cfg);
135
+ if (accounts.length === 0) {
136
+ return null;
137
+ }
138
+ return {
139
+ actions: ['send', 'edit', 'delete', 'sticker', 'sendAttachment', 'pin', 'unpin'],
140
+ // presentation → inline keyboard + MAX markdown (presentation.ts);
141
+ // delivery-pin → PUT /chats/{chatId}/pin on the sent message.
142
+ capabilities: ['presentation', 'delivery-pin'],
143
+ };
144
+ },
145
+ extractToolSend: ({ args }) => {
146
+ // Extract routing info for ALL actions (send, edit, delete, sticker)
147
+ // Core uses extractToolSend for routing all message tool actions to plugin
148
+ let to = typeof args.target === 'string'
149
+ ? args.target
150
+ : typeof args.to === 'string'
151
+ ? args.to
152
+ : typeof args.chatId === 'string'
153
+ ? args.chatId
154
+ : typeof args.channelId === 'string'
155
+ ? args.channelId
156
+ : undefined;
157
+ if (!to) {
158
+ // For edit/delete, target may not be present — use a placeholder
159
+ // so core still routes to this plugin's handleAction
160
+ to = typeof args.messageId === 'string' ? '__message_action__' : undefined;
161
+ }
162
+ if (!to) {
163
+ return null;
164
+ }
165
+ // Strip provider prefix (e.g. "max:188862440" → "188862440")
166
+ if (to.startsWith('max:'))
167
+ to = to.slice(4);
168
+ const accountId = typeof args.accountId === 'string' ? args.accountId.trim() : undefined;
169
+ return { to, accountId };
170
+ },
171
+ handleAction: async ({ action, params, cfg, accountId }) => {
172
+ const account = resolveMaxAccount({
173
+ cfg,
174
+ accountId,
175
+ });
176
+ if (!account.token) {
177
+ throw new Error('MAX bot token not configured');
178
+ }
179
+ // Strip provider prefix from target (e.g. "max:188862440" → "188862440")
180
+ const stripPrefix = (val) => {
181
+ if (!val)
182
+ return val;
183
+ return val.startsWith('max:') ? val.slice(4) : val;
184
+ };
185
+ if (action === 'send') {
186
+ const to = stripPrefix(readTargetParam(params));
187
+ const presentation = normalizeMessagePresentation(params.presentation);
188
+ const pin = readMaxDeliveryPin(params.delivery, params.pin);
189
+ const content = readStringParam(params, 'message', {
190
+ required: !presentation,
191
+ allowEmpty: true,
192
+ }) ?? '';
193
+ // Pin the sent message when delivery.pin (or pin=true) was requested.
194
+ // Optional pin failures degrade; a required one fails the action.
195
+ // Dialogs cannot pin: that is reported as pinned:false with pinSkipped.
196
+ const withPin = async (messageId, chatType, extra = {}) => {
197
+ if (!pin || !messageId)
198
+ return jsonResult({ ok: true, to, messageId, ...extra });
199
+ try {
200
+ const pinned = await pinMaxMessage(to, messageId, {
201
+ token: account.token,
202
+ pinNotify: pin.notify === true,
203
+ chatType,
204
+ });
205
+ if (!pinned.pinned) {
206
+ return jsonResult({
207
+ ok: true,
208
+ to,
209
+ messageId,
210
+ ...extra,
211
+ pinned: false,
212
+ pinSkipped: pinned.reason,
213
+ });
214
+ }
215
+ return jsonResult({ ok: true, to, messageId, ...extra, pinned: true });
216
+ }
217
+ catch (err) {
218
+ if (pin.required)
219
+ throw err;
220
+ return jsonResult({
221
+ ok: true,
222
+ to,
223
+ messageId,
224
+ ...extra,
225
+ pinned: false,
226
+ pinError: String(err),
227
+ });
228
+ }
229
+ };
230
+ // Core normally renders presentation through the outbound adapter; this
231
+ // path covers direct plugin dispatch with the same render policy.
232
+ if (presentation) {
233
+ const rendered = await materializeMaxPresentation({ text: content, presentation });
234
+ const renderedButtons = readMaxChannelButtons(rendered.channelData);
235
+ const text = rendered.text ?? '';
236
+ const chunks = text.length > MAX_TEXT_LIMIT
237
+ ? getMaxRuntime().channel.text.chunkMarkdownText(text, MAX_TEXT_LIMIT)
238
+ : [text];
239
+ let firstMessageId = '';
240
+ let firstChatType;
241
+ for (let index = 0; index < chunks.length; index += 1) {
242
+ const sent = await sendMaxMessage(to, chunks[index], {
243
+ token: account.token,
244
+ replyToMessageId: index === 0 ? (readStringParam(params, 'replyTo') ?? undefined) : undefined,
245
+ format: 'markdown',
246
+ buttons: index === chunks.length - 1 ? renderedButtons : undefined,
247
+ });
248
+ if (index === 0) {
249
+ firstMessageId = sent.messageId;
250
+ firstChatType = sent.raw.message?.recipient?.chat_type;
251
+ }
252
+ }
253
+ return withPin(firstMessageId, firstChatType);
254
+ }
255
+ const replyTo = readStringParam(params, 'replyTo');
256
+ const stickerId = readStringParam(params, 'stickerId');
257
+ // Inline keyboard buttons: [[{text, type?, payload?|callback_data?, url?, webApp?}]]
258
+ // type: callback (default) | link | message | clipboard | open_app | request_contact | request_geo_location
259
+ const buttons = readMaxSendButtons(params.buttons);
260
+ // Sticker sending (by sticker code)
261
+ if (stickerId) {
262
+ // stickerId can be a single id or comma-separated
263
+ const codes = Array.isArray(params.stickerId)
264
+ ? params.stickerId
265
+ : [stickerId];
266
+ const firstCode = codes[0];
267
+ if (firstCode) {
268
+ const result = await sendMaxSticker(to, firstCode, {
269
+ token: account.token,
270
+ replyToMessageId: replyTo ?? undefined,
271
+ });
272
+ return withPin(result.messageId, result.raw.message?.recipient?.chat_type);
273
+ }
274
+ }
275
+ // Location sending: coordinates from latitude/longitude or location="LAT,LNG"
276
+ const location = readLocationParams(params);
277
+ if (location) {
278
+ const result = await sendMaxLocation(to, location, content || undefined, {
279
+ token: account.token,
280
+ replyToMessageId: replyTo ?? undefined,
281
+ format: 'markdown',
282
+ });
283
+ return withPin(result.messageId, result.raw.message?.recipient?.chat_type);
284
+ }
285
+ // Contact sending: if contactName param exists
286
+ const contactName = readStringParam(params, 'contactName');
287
+ if (contactName) {
288
+ const result = await sendMaxContact(to, readContactParams(params, contactName), {
289
+ token: account.token,
290
+ replyToMessageId: replyTo ?? undefined,
291
+ });
292
+ return withPin(result.messageId, result.raw.message?.recipient?.chat_type);
293
+ }
294
+ // Media: direct media fields and every structured attachments[] item.
295
+ const mediaSources = readMediaSources(params);
296
+ if (mediaSources.length) {
297
+ // Local path or URL: https image links go by URL, other remote media
298
+ // is downloaded to a temp file and uploaded.
299
+ const result = await sendMaxMediaSources(to, content, mediaSources, {
300
+ token: account.token,
301
+ replyToMessageId: replyTo ?? undefined,
302
+ format: 'markdown',
303
+ mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
304
+ });
305
+ return withPin(result.messageId, result.chatType, mediaResultExtra(result));
306
+ }
307
+ const result = await sendMaxMessage(to, content, {
308
+ token: account.token,
309
+ replyToMessageId: replyTo ?? undefined,
310
+ format: 'markdown',
311
+ buttons,
312
+ });
313
+ return withPin(result.messageId, result.raw.message?.recipient?.chat_type);
314
+ }
315
+ if (action === 'edit') {
316
+ const messageId = readStringParam(params, 'messageId', { required: true });
317
+ const text = readStringParam(params, 'message', {
318
+ required: true,
319
+ allowEmpty: true,
320
+ });
321
+ await editMaxMessage(messageId, text, {
322
+ token: account.token,
323
+ format: 'markdown',
324
+ });
325
+ return jsonResult({ ok: true, messageId });
326
+ }
327
+ if (action === 'delete') {
328
+ const messageId = readStringParam(params, 'messageId', { required: true });
329
+ await deleteMaxMessage(messageId, {
330
+ token: account.token,
331
+ });
332
+ return jsonResult({ ok: true, messageId });
333
+ }
334
+ if (action === 'pin') {
335
+ const to = stripPrefix(readTargetParam(params));
336
+ const messageId = readStringParam(params, 'messageId', { required: true });
337
+ const notifyParam = params.notify;
338
+ const pinned = await pinMaxMessage(to, messageId, {
339
+ token: account.token,
340
+ pinNotify: typeof notifyParam === 'boolean' ? notifyParam : undefined,
341
+ });
342
+ if (!pinned.pinned) {
343
+ return jsonResult({ ok: true, to, messageId, pinned: false, reason: pinned.reason });
344
+ }
345
+ return jsonResult({ ok: true, to, messageId, pinned: true });
346
+ }
347
+ if (action === 'unpin') {
348
+ const to = stripPrefix(readTargetParam(params));
349
+ await unpinMaxMessage(to, { token: account.token });
350
+ return jsonResult({ ok: true, to, pinned: false });
351
+ }
352
+ if (action === 'sticker') {
353
+ const to = stripPrefix(readTargetParam(params));
354
+ // stickerId may come as string or string[] from message tool schema
355
+ const rawStickerId = params.stickerId;
356
+ let stickerCode = Array.isArray(rawStickerId)
357
+ ? rawStickerId[0]?.trim()
358
+ : (readStringParam(params, 'stickerId') ?? readStringParam(params, 'fileId'));
359
+ // Auto-fill from last received sticker if not provided
360
+ if (!stickerCode) {
361
+ stickerCode = getLastStickerCode(to) ?? getLastStickerCode() ?? undefined;
362
+ }
363
+ if (!stickerCode) {
364
+ throw new Error('stickerId is required. Send a sticker first, then ask to send it back.');
365
+ }
366
+ const replyTo = readStringParam(params, 'replyTo');
367
+ const result = await sendMaxSticker(to, stickerCode, {
368
+ token: account.token,
369
+ replyToMessageId: replyTo ?? undefined,
370
+ });
371
+ return jsonResult({ ok: true, to, messageId: result.messageId });
372
+ }
373
+ if (action === 'sendAttachment') {
374
+ const to = stripPrefix(readTargetParam(params));
375
+ const replyTo = readStringParam(params, 'replyTo');
376
+ const caption = readStringParam(params, 'message') ?? readStringParam(params, 'caption') ?? '';
377
+ const attachType = readStringParam(params, 'type') ?? readStringParam(params, 'attachmentType') ?? '';
378
+ const mediaSources = readMediaSources(params);
379
+ if (mediaSources.length) {
380
+ const result = await sendMaxMediaSources(to, caption, mediaSources, {
381
+ token: account.token,
382
+ replyToMessageId: replyTo ?? undefined,
383
+ format: 'markdown',
384
+ mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
385
+ });
386
+ return jsonResult({
387
+ ok: true,
388
+ to,
389
+ messageId: result.messageId,
390
+ ...mediaResultExtra(result),
391
+ });
392
+ }
393
+ // Location attachment
394
+ if (attachType === 'location' ||
395
+ params.latitude != null ||
396
+ params.longitude != null ||
397
+ readStringParam(params, 'location')) {
398
+ const location = readLocationParams(params);
399
+ if (location) {
400
+ const result = await sendMaxLocation(to, location, caption || undefined, {
401
+ token: account.token,
402
+ replyToMessageId: replyTo ?? undefined,
403
+ });
404
+ return jsonResult({ ok: true, to, messageId: result.messageId });
405
+ }
406
+ throw new Error("Invalid location: provide latitude/longitude or location='LAT,LNG'");
407
+ }
408
+ // Contact attachment
409
+ if (attachType === 'contact' || readStringParam(params, 'contactName')) {
410
+ const contactName = readStringParam(params, 'contactName') ?? readStringParam(params, 'name') ?? 'Unknown';
411
+ const result = await sendMaxContact(to, readContactParams(params, contactName), {
412
+ token: account.token,
413
+ replyToMessageId: replyTo ?? undefined,
414
+ });
415
+ return jsonResult({ ok: true, to, messageId: result.messageId });
416
+ }
417
+ throw new Error("sendAttachment: unknown type. Use media/filePath for files, or type='location' / type='contact'");
418
+ }
419
+ throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
420
+ },
421
+ };