@aspalagin/openclaw-max 0.7.3 → 0.8.1

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 (85) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +412 -344
  3. package/README_EN.md +492 -0
  4. package/SKILL.md +45 -37
  5. package/assets/icon.png +0 -0
  6. package/dist/secret-contract-api.js +6 -0
  7. package/dist/src/access-policy.js +123 -0
  8. package/dist/src/accounts.js +170 -23
  9. package/dist/src/action-scope.js +213 -0
  10. package/dist/src/actions.js +159 -31
  11. package/dist/src/api.js +99 -83
  12. package/dist/src/callbacks.js +27 -7
  13. package/dist/src/channel-agent-prompt.js +8 -19
  14. package/dist/src/channel-config.js +25 -3
  15. package/dist/src/channel-lifecycle.js +28 -5
  16. package/dist/src/channel-outbound.js +43 -9
  17. package/dist/src/channel-policy.js +19 -6
  18. package/dist/src/channel.js +16 -0
  19. package/dist/src/command-menu.js +199 -0
  20. package/dist/src/config-schema.js +106 -10
  21. package/dist/src/deliver.js +144 -48
  22. package/dist/src/dispatch.js +12 -11
  23. package/dist/src/gateway-drain.js +40 -0
  24. package/dist/src/group-mention.js +129 -0
  25. package/dist/src/inbound-attachments.js +162 -20
  26. package/dist/src/inbound.js +388 -134
  27. package/dist/src/media-temp.js +31 -20
  28. package/dist/src/network.js +216 -0
  29. package/dist/src/onboarding.js +4 -2
  30. package/dist/src/polling.js +180 -12
  31. package/dist/src/presentation.js +11 -0
  32. package/dist/src/progress-draft.js +168 -0
  33. package/dist/src/runtime.js +15 -0
  34. package/dist/src/secret-contract.js +149 -0
  35. package/dist/src/send.js +250 -46
  36. package/dist/src/stream-draft.js +83 -41
  37. package/dist/src/turn-adoption.js +24 -0
  38. package/dist/src/typing.js +55 -0
  39. package/dist/src/update-journal.js +267 -0
  40. package/dist/src/webhook-queue.js +151 -26
  41. package/dist/src/webhook-runner.js +61 -9
  42. package/dist/src/webhook.js +34 -15
  43. package/openclaw.plugin.json +983 -8
  44. package/package.json +17 -4
  45. package/src/access-policy.ts +154 -0
  46. package/src/accounts.ts +260 -25
  47. package/src/action-scope.ts +274 -0
  48. package/src/actions.ts +188 -35
  49. package/src/api.ts +158 -101
  50. package/src/callbacks.ts +26 -7
  51. package/src/channel-agent-prompt.ts +8 -19
  52. package/src/channel-config.ts +32 -3
  53. package/src/channel-lifecycle.ts +33 -6
  54. package/src/channel-outbound.ts +50 -7
  55. package/src/channel-policy.ts +27 -7
  56. package/src/channel.ts +16 -0
  57. package/src/command-menu.ts +277 -0
  58. package/src/config-schema.ts +111 -10
  59. package/src/deliver.ts +174 -55
  60. package/src/dispatch.ts +12 -11
  61. package/src/gateway-drain.ts +45 -0
  62. package/src/group-mention.ts +191 -0
  63. package/src/inbound-attachments.ts +173 -22
  64. package/src/inbound.ts +443 -130
  65. package/src/media-temp.ts +35 -23
  66. package/src/network.ts +233 -0
  67. package/src/onboarding.ts +4 -2
  68. package/src/polling.ts +221 -15
  69. package/src/presentation.ts +14 -1
  70. package/src/progress-draft.ts +226 -0
  71. package/src/runtime.ts +23 -0
  72. package/src/secret-contract.ts +183 -0
  73. package/src/send.ts +346 -59
  74. package/src/stream-draft.ts +102 -41
  75. package/src/turn-adoption.ts +33 -0
  76. package/src/types.ts +9 -3
  77. package/src/typing.ts +77 -0
  78. package/src/update-journal.ts +302 -0
  79. package/src/webhook-queue.ts +205 -33
  80. package/src/webhook-runner.ts +65 -8
  81. package/src/webhook.ts +39 -18
  82. package/scripts/check-cycles.mjs +0 -38
  83. package/scripts/test-api.mjs +0 -169
  84. package/scripts/test-send.mjs +0 -162
  85. package/scripts/update-schema.mjs +0 -48
package/src/actions.ts CHANGED
@@ -2,22 +2,37 @@
2
2
  * MAX channel message actions adapter — implements message tool actions
3
3
  */
4
4
 
5
- import type { ChannelMessageActionAdapter } from 'openclaw/plugin-sdk/channel-contract';
5
+ import { createActionGate } from 'openclaw/plugin-sdk/channel-actions';
6
+ import type {
7
+ ChannelMessageActionAdapter,
8
+ ChannelMessageActionContext,
9
+ } from 'openclaw/plugin-sdk/channel-contract';
6
10
  import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
7
11
  import { normalizeMessagePresentation } from 'openclaw/plugin-sdk/interactive-runtime';
12
+ import { getAgentScopedMediaLocalRoots } from 'openclaw/plugin-sdk/media-local-roots';
8
13
  import { readStringParam } from 'openclaw/plugin-sdk/param-readers';
9
14
  import { jsonResult } from 'openclaw/plugin-sdk/tool-results';
10
15
 
11
16
  import { listMaxAccountIds, resolveMaxAccount } from './accounts.js';
12
- import { materializeMaxPresentation, MAX_TEXT_LIMIT, readMaxDeliveryPin } from './presentation.js';
17
+ import { assertMaxActionInScope } from './action-scope.js';
18
+ import { sanitizeMaxFileName } from './media-temp.js';
19
+ import {
20
+ materializeMaxPresentation,
21
+ readMaxDeliveryPin,
22
+ resolveMaxTextChunkLimit,
23
+ } from './presentation.js';
13
24
  import { getMaxRuntime } from './runtime.js';
14
25
  import {
15
26
  deleteMaxMessage,
27
+ describeMaxMediaSource,
16
28
  editMaxMessage,
29
+ type MaxLocalMediaAccess,
17
30
  type MaxMediaSendOptions,
31
+ type MaxMediaSource,
18
32
  pinMaxMessage,
19
33
  readMaxChannelButtons,
20
34
  readMaxSendButtons,
35
+ resolveMaxSendFlags,
21
36
  sendMaxContact,
22
37
  sendMaxLocation,
23
38
  sendMaxMediaGroup,
@@ -30,6 +45,72 @@ import { getLastStickerCode } from './sticker-cache.js';
30
45
 
31
46
  const providerId = 'max';
32
47
  const mediaSourceKeys = ['media', 'filePath', 'path', 'fileUrl', 'url', 'buffer', 'image'] as const;
48
+ /**
49
+ * Params that carry a media path or URL, per action: core normalizes them for
50
+ * the sandbox and scopes outbound media access to them. `buffer` is base64
51
+ * content for core, not a path, so it is not declared.
52
+ */
53
+ const mediaSourceParams = mediaSourceKeys.filter((key) => key !== 'buffer');
54
+
55
+ /** `data:<type>;base64,<data>` — a data URL in `buffer`. */
56
+ const DATA_URL_RE = /^data:([^;,]*)(?:;[^,]*)?;base64,/i;
57
+
58
+ /**
59
+ * Inline content from `buffer` (base64 or a base64 data URL) with `filename`
60
+ * and `contentType` (aliases fileName, mimeType) of the same object. The size
61
+ * limit is checked before decoding, so an oversized payload is refused
62
+ * without allocating it.
63
+ */
64
+ function readInlineMedia(
65
+ source: Record<string, unknown>,
66
+ maxBytes: number,
67
+ ): MaxMediaSource | undefined {
68
+ const raw = typeof source.buffer === 'string' ? source.buffer : undefined;
69
+ if (!raw?.trim()) return undefined;
70
+ const dataUrl = DATA_URL_RE.exec(raw);
71
+ const base64 = (dataUrl ? raw.slice(dataUrl[0].length) : raw).replace(/\s+/g, '');
72
+ if (!/^[A-Za-z0-9+/_-]*={0,2}$/.test(base64)) {
73
+ throw new Error('buffer is not valid base64 content');
74
+ }
75
+ const decodedBytes = Math.floor((base64.replace(/=+$/, '').length * 3) / 4);
76
+ if (decodedBytes > maxBytes) {
77
+ throw new Error(
78
+ `buffer is ${decodedBytes} bytes, over the ${maxBytes}-byte media limit (mediaMaxMb)`,
79
+ );
80
+ }
81
+ const buffer = Buffer.from(
82
+ base64,
83
+ base64.includes('-') || base64.includes('_') ? 'base64url' : 'base64',
84
+ );
85
+ if (buffer.byteLength === 0) throw new Error('buffer is empty');
86
+ const pick = (...keys: string[]) =>
87
+ keys
88
+ .map((key) => source[key])
89
+ .find((value): value is string => typeof value === 'string' && value.trim() !== '')
90
+ ?.trim();
91
+ const contentType = pick('contentType', 'mimeType') ?? (dataUrl?.[1] || undefined);
92
+ return {
93
+ buffer,
94
+ fileName: sanitizeMaxFileName(pick('filename', 'fileName'), contentType),
95
+ contentType,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Local media access for an action: the roots and host reader core passed
101
+ * with the action; without them (direct plugin dispatch) the agent-scoped
102
+ * default roots. Local files are read only through this policy.
103
+ */
104
+ function resolveActionLocalMedia(ctx: ChannelMessageActionContext): MaxLocalMediaAccess {
105
+ if (ctx.mediaAccess || ctx.mediaLocalRoots?.length || ctx.mediaReadFile) {
106
+ return {
107
+ mediaAccess: ctx.mediaAccess,
108
+ mediaLocalRoots: ctx.mediaLocalRoots,
109
+ mediaReadFile: ctx.mediaReadFile,
110
+ };
111
+ }
112
+ return { mediaLocalRoots: getAgentScopedMediaLocalRoots(ctx.cfg, ctx.agentId ?? undefined) };
113
+ }
33
114
 
34
115
  function listEnabledAccounts(cfg: OpenClawConfig) {
35
116
  return listMaxAccountIds(cfg)
@@ -49,32 +130,29 @@ function readTargetParam(params: Record<string, unknown>, required = true): stri
49
130
  /**
50
131
  * Media sources in order: the direct media field first, then every
51
132
  * structured `attachments[]` item (one source per item). Duplicates dropped.
133
+ * A path or URL wins over `buffer` (core may fill both; the path goes through
134
+ * the guarded loader); `buffer` alone is sent as inline content.
52
135
  */
53
- function readMediaSources(params: Record<string, unknown>): string[] {
54
- const sources: string[] = [];
55
- const add = (value: string | undefined) => {
136
+ function readMediaSources(params: Record<string, unknown>, maxBytes: number): MaxMediaSource[] {
137
+ const sources: MaxMediaSource[] = [];
138
+ const add = (value: MaxMediaSource | undefined) => {
56
139
  if (value && !sources.includes(value)) sources.push(value);
57
140
  };
58
-
59
- for (const key of mediaSourceKeys) {
60
- const value = readStringParam(params, key, { trim: false });
61
- if (value) {
62
- add(value);
63
- break;
141
+ const readPath = (source: Record<string, unknown>): string | undefined => {
142
+ for (const key of mediaSourceParams) {
143
+ const value = readStringParam(source, key, { trim: false });
144
+ if (value) return value;
64
145
  }
65
- }
146
+ return undefined;
147
+ };
148
+
149
+ add(readPath(params) ?? readInlineMedia(params, maxBytes));
66
150
 
67
151
  if (Array.isArray(params.attachments)) {
68
152
  for (const item of params.attachments) {
69
153
  if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
70
154
  const attachment = item as Record<string, unknown>;
71
- for (const key of mediaSourceKeys) {
72
- const value = typeof attachment[key] === 'string' ? attachment[key] : undefined;
73
- if (value) {
74
- add(value);
75
- break;
76
- }
77
- }
155
+ add(readPath(attachment) ?? readInlineMedia(attachment, maxBytes));
78
156
  }
79
157
  }
80
158
 
@@ -89,7 +167,7 @@ function readMediaSources(params: Record<string, unknown>): string[] {
89
167
  async function sendMaxMediaSources(
90
168
  to: string,
91
169
  caption: string,
92
- sources: string[],
170
+ sources: MaxMediaSource[],
93
171
  opts: MaxMediaSendOptions,
94
172
  ): Promise<{
95
173
  messageId: string;
@@ -113,7 +191,7 @@ async function sendMaxMediaSources(
113
191
  ...opts,
114
192
  onError: (err, failed) => {
115
193
  firstError ??= err;
116
- errors.push(`${failed.join(', ')}: ${String(err)}`);
194
+ errors.push(`${failed.map(describeMaxMediaSource).join(', ')}: ${String(err)}`);
117
195
  },
118
196
  });
119
197
  if (messageIds.length === 0 && firstError !== undefined) throw firstError;
@@ -160,6 +238,13 @@ function readLocationParams(
160
238
  return { latitude: lat, longitude: lng };
161
239
  }
162
240
 
241
+ /** Voice delivery of audio: core's `asVoice` (or its `audioAsVoice` alias), boolean or "true". */
242
+ function readAsVoiceParam(params: Record<string, unknown>): boolean {
243
+ return [params.asVoice, params.audioAsVoice].some(
244
+ (value) => value === true || (typeof value === 'string' && value.trim() === 'true'),
245
+ );
246
+ }
247
+
163
248
  /** Contact card fields: contactId (MAX user id) and vcfPhone/phone next to the given name. */
164
249
  function readContactParams(
165
250
  params: Record<string, unknown>,
@@ -174,20 +259,47 @@ function readContactParams(
174
259
  };
175
260
  }
176
261
 
262
+ const MAX_MESSAGE_ACTIONS = [
263
+ 'send',
264
+ 'edit',
265
+ 'delete',
266
+ 'sticker',
267
+ 'sendAttachment',
268
+ 'pin',
269
+ 'unpin',
270
+ ] as const;
271
+
177
272
  export const maxMessageActions: ChannelMessageActionAdapter = {
178
- describeMessageTool: ({ cfg }) => {
273
+ describeMessageTool: ({ cfg, accountId }) => {
179
274
  const accounts = listEnabledAccounts(cfg);
180
275
  if (accounts.length === 0) {
181
276
  return null;
182
277
  }
278
+ // channels.max.actions.<action>: false hides the action from the tool.
279
+ const gate = createActionGate(resolveMaxAccount({ cfg, accountId }).config.actions);
183
280
  return {
184
- actions: ['send', 'edit', 'delete', 'sticker', 'sendAttachment', 'pin', 'unpin'],
281
+ actions: MAX_MESSAGE_ACTIONS.filter((name) => gate(name)),
185
282
  // presentation → inline keyboard + MAX markdown (presentation.ts);
186
283
  // delivery-pin → PUT /chats/{chatId}/pin on the sent message.
187
284
  capabilities: ['presentation', 'delivery-pin'],
285
+ mediaSourceParams: { send: mediaSourceParams, sendAttachment: mediaSourceParams },
188
286
  };
189
287
  },
190
288
 
289
+ // A send with a presentation is delivered by core itself (outbound adapter
290
+ // sendPayload), never reaching handleAction: the action gate and scope are
291
+ // checked here first, then core delivers the payload unchanged. Any other
292
+ // send declines (null) and keeps the handleAction path, which checks them.
293
+ prepareSendPayload: async ({ ctx, to, payload, replyToId }) => {
294
+ if (!payload.presentation) return null;
295
+ const account = resolveMaxAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
296
+ if (!createActionGate(account.config.actions)('send')) {
297
+ throw new Error('MAX action "send" is disabled by channels.max.actions');
298
+ }
299
+ await assertMaxActionInScope(ctx, account, { to, replyTo: replyToId });
300
+ return payload;
301
+ },
302
+
191
303
  extractToolSend: ({ args }: { args: Record<string, unknown> }) => {
192
304
  // Extract routing info for ALL actions (send, edit, delete, sticker)
193
305
  // Core uses extractToolSend for routing all message tool actions to plugin
@@ -209,13 +321,14 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
209
321
  if (!to) {
210
322
  return null;
211
323
  }
212
- // Strip provider prefix (e.g. "max:188862440" → "188862440")
324
+ // Strip provider prefix (e.g. "max:123456789" → "123456789")
213
325
  if (to.startsWith('max:')) to = to.slice(4);
214
326
  const accountId = typeof args.accountId === 'string' ? args.accountId.trim() : undefined;
215
327
  return { to, accountId };
216
328
  },
217
329
 
218
- handleAction: async ({ action, params, cfg, accountId }) => {
330
+ handleAction: async (ctx) => {
331
+ const { action, params, cfg, accountId } = ctx;
219
332
  const account = resolveMaxAccount({
220
333
  cfg,
221
334
  accountId,
@@ -223,8 +336,17 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
223
336
  if (!account.token) {
224
337
  throw new Error('MAX bot token not configured');
225
338
  }
339
+ if (!createActionGate(account.config.actions)(action)) {
340
+ throw new Error(`MAX action "${action}" is disabled by channels.max.actions`);
341
+ }
226
342
 
227
- // Strip provider prefix from target (e.g. "max:188862440" → "188862440")
343
+ // Notification and link-preview options of sends: core's `silent` flag
344
+ // beats the account defaults (channels.max.notify / disableLinkPreview).
345
+ const sendFlags = resolveMaxSendFlags(account.config, {
346
+ silent: typeof params.silent === 'boolean' ? params.silent : undefined,
347
+ });
348
+
349
+ // Strip provider prefix from target (e.g. "max:123456789" → "123456789")
228
350
  const stripPrefix = (val: string | undefined): string | undefined => {
229
351
  if (!val) return val;
230
352
  return val.startsWith('max:') ? val.slice(4) : val;
@@ -232,6 +354,10 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
232
354
 
233
355
  if (action === 'send') {
234
356
  const to = stripPrefix(readTargetParam(params))!;
357
+ await assertMaxActionInScope(ctx, account, {
358
+ to,
359
+ replyTo: readStringParam(params, 'replyTo'),
360
+ });
235
361
  const presentation = normalizeMessagePresentation(params.presentation);
236
362
  const pin = readMaxDeliveryPin(params.delivery, params.pin);
237
363
  const content =
@@ -285,15 +411,17 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
285
411
  const rendered = await materializeMaxPresentation({ text: content, presentation });
286
412
  const renderedButtons = readMaxChannelButtons(rendered.channelData);
287
413
  const text = rendered.text ?? '';
414
+ const chunkLimit = resolveMaxTextChunkLimit(cfg, account.accountId);
288
415
  const chunks =
289
- text.length > MAX_TEXT_LIMIT
290
- ? getMaxRuntime().channel.text.chunkMarkdownText(text, MAX_TEXT_LIMIT)
416
+ text.length > chunkLimit
417
+ ? getMaxRuntime().channel.text.chunkMarkdownText(text, chunkLimit)
291
418
  : [text];
292
419
  let firstMessageId = '';
293
420
  let firstChatType: string | undefined;
294
421
  for (let index = 0; index < chunks.length; index += 1) {
295
422
  const sent = await sendMaxMessage(to, chunks[index], {
296
423
  token: account.token,
424
+ ...sendFlags,
297
425
  replyToMessageId:
298
426
  index === 0 ? (readStringParam(params, 'replyTo') ?? undefined) : undefined,
299
427
  format: 'markdown',
@@ -323,6 +451,7 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
323
451
  if (firstCode) {
324
452
  const result = await sendMaxSticker(to, firstCode, {
325
453
  token: account.token,
454
+ ...sendFlags,
326
455
  replyToMessageId: replyTo ?? undefined,
327
456
  });
328
457
  return withPin(result.messageId, result.raw.message?.recipient?.chat_type);
@@ -334,6 +463,7 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
334
463
  if (location) {
335
464
  const result = await sendMaxLocation(to, location, content || undefined, {
336
465
  token: account.token,
466
+ ...sendFlags,
337
467
  replyToMessageId: replyTo ?? undefined,
338
468
  format: 'markdown',
339
469
  });
@@ -345,28 +475,35 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
345
475
  if (contactName) {
346
476
  const result = await sendMaxContact(to, readContactParams(params, contactName), {
347
477
  token: account.token,
478
+ ...sendFlags,
348
479
  replyToMessageId: replyTo ?? undefined,
349
480
  });
350
481
  return withPin(result.messageId, result.raw.message?.recipient?.chat_type);
351
482
  }
352
483
 
353
484
  // Media: direct media fields and every structured attachments[] item.
354
- const mediaSources = readMediaSources(params);
485
+ const mediaMaxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
486
+ const mediaSources = readMediaSources(params, mediaMaxBytes);
355
487
 
356
488
  if (mediaSources.length) {
357
- // Local path or URL: https image links go by URL, other remote media
358
- // is downloaded to a temp file and uploaded.
489
+ // Local paths only under the allowed roots (resolveActionLocalMedia);
490
+ // public https image links go by URL, other remote media is
491
+ // downloaded through the SSRF-guarded fetcher and uploaded.
359
492
  const result = await sendMaxMediaSources(to, content, mediaSources, {
360
493
  token: account.token,
494
+ ...sendFlags,
361
495
  replyToMessageId: replyTo ?? undefined,
362
496
  format: 'markdown',
363
- mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
497
+ mediaMaxBytes,
498
+ localMedia: resolveActionLocalMedia(ctx),
499
+ asVoice: readAsVoiceParam(params),
364
500
  });
365
501
  return withPin(result.messageId, result.chatType, mediaResultExtra(result));
366
502
  }
367
503
 
368
504
  const result = await sendMaxMessage(to, content, {
369
505
  token: account.token,
506
+ ...sendFlags,
370
507
  replyToMessageId: replyTo ?? undefined,
371
508
  format: 'markdown',
372
509
  buttons,
@@ -376,6 +513,7 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
376
513
 
377
514
  if (action === 'edit') {
378
515
  const messageId = readStringParam(params, 'messageId', { required: true });
516
+ await assertMaxActionInScope(ctx, account, { messageId });
379
517
  const text = readStringParam(params, 'message', {
380
518
  required: true,
381
519
  allowEmpty: true,
@@ -389,6 +527,7 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
389
527
 
390
528
  if (action === 'delete') {
391
529
  const messageId = readStringParam(params, 'messageId', { required: true });
530
+ await assertMaxActionInScope(ctx, account, { messageId });
392
531
  await deleteMaxMessage(messageId, {
393
532
  token: account.token,
394
533
  });
@@ -398,6 +537,7 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
398
537
  if (action === 'pin') {
399
538
  const to = stripPrefix(readTargetParam(params))!;
400
539
  const messageId = readStringParam(params, 'messageId', { required: true });
540
+ await assertMaxActionInScope(ctx, account, { to });
401
541
  const notifyParam = params.notify;
402
542
  const pinned = await pinMaxMessage(to, messageId, {
403
543
  token: account.token,
@@ -411,12 +551,17 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
411
551
 
412
552
  if (action === 'unpin') {
413
553
  const to = stripPrefix(readTargetParam(params))!;
554
+ await assertMaxActionInScope(ctx, account, { to });
414
555
  await unpinMaxMessage(to, { token: account.token });
415
556
  return jsonResult({ ok: true, to, pinned: false });
416
557
  }
417
558
 
418
559
  if (action === 'sticker') {
419
560
  const to = stripPrefix(readTargetParam(params))!;
561
+ await assertMaxActionInScope(ctx, account, {
562
+ to,
563
+ replyTo: readStringParam(params, 'replyTo'),
564
+ });
420
565
  // stickerId may come as string or string[] from message tool schema
421
566
  const rawStickerId = params.stickerId;
422
567
  let stickerCode: string | undefined = Array.isArray(rawStickerId)
@@ -433,6 +578,7 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
433
578
 
434
579
  const result = await sendMaxSticker(to, stickerCode, {
435
580
  token: account.token,
581
+ ...sendFlags,
436
582
  replyToMessageId: replyTo ?? undefined,
437
583
  });
438
584
  return jsonResult({ ok: true, to, messageId: result.messageId });
@@ -441,18 +587,23 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
441
587
  if (action === 'sendAttachment') {
442
588
  const to = stripPrefix(readTargetParam(params))!;
443
589
  const replyTo = readStringParam(params, 'replyTo');
590
+ await assertMaxActionInScope(ctx, account, { to, replyTo });
444
591
  const caption =
445
592
  readStringParam(params, 'message') ?? readStringParam(params, 'caption') ?? '';
446
593
  const attachType =
447
594
  readStringParam(params, 'type') ?? readStringParam(params, 'attachmentType') ?? '';
448
- const mediaSources = readMediaSources(params);
595
+ const mediaMaxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
596
+ const mediaSources = readMediaSources(params, mediaMaxBytes);
449
597
 
450
598
  if (mediaSources.length) {
451
599
  const result = await sendMaxMediaSources(to, caption, mediaSources, {
452
600
  token: account.token,
601
+ ...sendFlags,
453
602
  replyToMessageId: replyTo ?? undefined,
454
603
  format: 'markdown',
455
- mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
604
+ mediaMaxBytes,
605
+ localMedia: resolveActionLocalMedia(ctx),
606
+ asVoice: readAsVoiceParam(params),
456
607
  });
457
608
  return jsonResult({
458
609
  ok: true,
@@ -473,6 +624,7 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
473
624
  if (location) {
474
625
  const result = await sendMaxLocation(to, location, caption || undefined, {
475
626
  token: account.token,
627
+ ...sendFlags,
476
628
  replyToMessageId: replyTo ?? undefined,
477
629
  });
478
630
  return jsonResult({ ok: true, to, messageId: result.messageId });
@@ -486,13 +638,14 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
486
638
  readStringParam(params, 'contactName') ?? readStringParam(params, 'name') ?? 'Unknown';
487
639
  const result = await sendMaxContact(to, readContactParams(params, contactName), {
488
640
  token: account.token,
641
+ ...sendFlags,
489
642
  replyToMessageId: replyTo ?? undefined,
490
643
  });
491
644
  return jsonResult({ ok: true, to, messageId: result.messageId });
492
645
  }
493
646
 
494
647
  throw new Error(
495
- "sendAttachment: unknown type. Use media/filePath for files, or type='location' / type='contact'",
648
+ "sendAttachment: unknown type. Use media/filePath (or base64 buffer with filename) for files, or type='location' / type='contact'",
496
649
  );
497
650
  }
498
651