@aspalagin/openclaw-max 0.7.2 → 0.8.0

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 (83) 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.js +16 -0
  18. package/dist/src/command-menu.js +199 -0
  19. package/dist/src/config-schema.js +106 -10
  20. package/dist/src/deliver.js +144 -48
  21. package/dist/src/dispatch.js +12 -11
  22. package/dist/src/gateway-drain.js +40 -0
  23. package/dist/src/group-mention.js +129 -0
  24. package/dist/src/inbound-attachments.js +162 -20
  25. package/dist/src/inbound.js +388 -134
  26. package/dist/src/media-temp.js +31 -20
  27. package/dist/src/network.js +216 -0
  28. package/dist/src/onboarding.js +4 -2
  29. package/dist/src/polling.js +180 -12
  30. package/dist/src/presentation.js +11 -0
  31. package/dist/src/progress-draft.js +168 -0
  32. package/dist/src/runtime.js +15 -0
  33. package/dist/src/secret-contract.js +149 -0
  34. package/dist/src/send.js +250 -46
  35. package/dist/src/stream-draft.js +83 -41
  36. package/dist/src/turn-adoption.js +24 -0
  37. package/dist/src/typing.js +55 -0
  38. package/dist/src/update-journal.js +267 -0
  39. package/dist/src/webhook-queue.js +151 -26
  40. package/dist/src/webhook-runner.js +61 -9
  41. package/dist/src/webhook.js +34 -15
  42. package/openclaw.plugin.json +986 -13
  43. package/package.json +25 -5
  44. package/src/access-policy.ts +154 -0
  45. package/src/accounts.ts +260 -25
  46. package/src/action-scope.ts +274 -0
  47. package/src/actions.ts +188 -35
  48. package/src/api.ts +158 -101
  49. package/src/callbacks.ts +26 -7
  50. package/src/channel-agent-prompt.ts +8 -19
  51. package/src/channel-config.ts +32 -3
  52. package/src/channel-lifecycle.ts +33 -6
  53. package/src/channel-outbound.ts +50 -7
  54. package/src/channel.ts +16 -0
  55. package/src/command-menu.ts +277 -0
  56. package/src/config-schema.ts +111 -10
  57. package/src/deliver.ts +174 -55
  58. package/src/dispatch.ts +12 -11
  59. package/src/gateway-drain.ts +45 -0
  60. package/src/group-mention.ts +191 -0
  61. package/src/inbound-attachments.ts +173 -22
  62. package/src/inbound.ts +443 -130
  63. package/src/media-temp.ts +35 -23
  64. package/src/network.ts +233 -0
  65. package/src/onboarding.ts +4 -2
  66. package/src/polling.ts +221 -15
  67. package/src/presentation.ts +14 -1
  68. package/src/progress-draft.ts +226 -0
  69. package/src/runtime.ts +23 -0
  70. package/src/secret-contract.ts +183 -0
  71. package/src/send.ts +346 -59
  72. package/src/stream-draft.ts +102 -41
  73. package/src/turn-adoption.ts +33 -0
  74. package/src/types.ts +9 -3
  75. package/src/typing.ts +77 -0
  76. package/src/update-journal.ts +302 -0
  77. package/src/webhook-queue.ts +205 -33
  78. package/src/webhook-runner.ts +65 -8
  79. package/src/webhook.ts +39 -18
  80. package/scripts/check-cycles.mjs +0 -38
  81. package/scripts/test-api.mjs +0 -169
  82. package/scripts/test-send.mjs +0 -162
  83. package/scripts/update-schema.mjs +0 -48
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Scoping of message-tool actions to chats the channel policy admits. Core
3
+ * guards cross-conversation mutations only when tools.message.crossContext
4
+ * is tightened (both options default to allow), and never guards edit/delete
5
+ * addressed by messageId alone: without this check a prompt injection in one
6
+ * chat could post, edit, delete or pin in any other chat of the bot.
7
+ */
8
+ import { ToolAuthorizationError } from 'openclaw/plugin-sdk/channel-actions';
9
+ import { normalizeAccountId } from 'openclaw/plugin-sdk/core';
10
+ import { admitMaxDmUser, admitMaxGroupMessage } from './access-policy.js';
11
+ import { MaxApi, MaxApiError } from './api.js';
12
+ import { isChatNotFound } from './send.js';
13
+ /**
14
+ * Throws ToolAuthorizationError (a tool error the agent sees) when the action
15
+ * may not run in its chat under channels.max.actionScope. Only a call core
16
+ * marks senderIsOwner=true is not scoped (CLI `openclaw message`, an admin's
17
+ * Control UI chat, the owner's turn). Every other call, the flag missing
18
+ * included (heartbeat, subagents, scheduled runs without message authority),
19
+ * is held to the chats the inbound policy admits.
20
+ */
21
+ export async function assertMaxActionInScope(ctx, account, target) {
22
+ const scope = account.config.actionScope ?? 'admitted';
23
+ if (scope === 'off' || ctx.senderIsOwner === true)
24
+ return;
25
+ const turnChatId = readCurrentMaxChatId(ctx);
26
+ const requesterId = ctx.requesterSenderId?.trim() || undefined;
27
+ // The turn's chat is current only for the account the turn came in on:
28
+ // another bot of the same group is held to its own policy.
29
+ const turnAccountId = ctx.requesterAccountId?.trim();
30
+ const currentChatId = !turnAccountId || normalizeAccountId(turnAccountId) === account.accountId
31
+ ? turnChatId
32
+ : undefined;
33
+ const api = new MaxApi({ token: account.token });
34
+ const { chat: located, authorId } = await failClosed(ctx.action, target, () => locateActionChat(api, target));
35
+ const replyTo = 'to' in target ? target.replyTo?.trim() || undefined : undefined;
36
+ if (isCurrentChat(located, currentChatId, requesterId)) {
37
+ if (replyTo)
38
+ await assertReplyInChat(ctx, api, replyTo, located, currentChatId);
39
+ return;
40
+ }
41
+ if (scope === 'current') {
42
+ deny(ctx.action, describeChat(located), 'actionScope="current" allows only the chat of the current turn');
43
+ }
44
+ const chat = await failClosed(ctx.action, target, () => classifyChat(api, located));
45
+ // A group admits the action only for a requester its sender allowlist admits.
46
+ const admission = chat.kind === 'group'
47
+ ? admitMaxGroupMessage(account, ctx.cfg, chat.chatId, requesterId)
48
+ : await admitMaxDmUser(account, chat.userId);
49
+ if (!admission.admitted)
50
+ deny(ctx.action, describeChat(chat), admission.reason);
51
+ // Outside the chat of the turn, edit and delete reach only the bot's own
52
+ // messages: what others wrote in an admitted chat is not the agent's to change.
53
+ if ('messageId' in target)
54
+ await assertBotMessage(ctx.action, api, target.messageId, authorId);
55
+ if (replyTo)
56
+ await assertReplyInChat(ctx, api, replyTo, chat);
57
+ }
58
+ /**
59
+ * A reply quotes its message in the chat it is sent to: that message must
60
+ * belong to the same chat, or the quote would carry another chat's text
61
+ * there. GET /messages/{mid} runs only for a reply; the message that started
62
+ * the turn needs no lookup in its own chat.
63
+ */
64
+ async function assertReplyInChat(ctx, api, replyTo, target, currentChatId) {
65
+ const turnMessageId = ctx.toolContext?.currentMessageId;
66
+ if (currentChatId && turnMessageId != null && String(turnMessageId).trim() === replyTo)
67
+ return;
68
+ const refuse = (why) => {
69
+ throw new ToolAuthorizationError(`MAX ${ctx.action} refused: replyTo message ${replyTo} ${why}; a reply may quote only a message ` +
70
+ `of the chat it is sent to, here ${describeChat(target)} (channels.max.actionScope).`);
71
+ };
72
+ let replyChat;
73
+ try {
74
+ replyChat = chatOfMessage(await api.getMessageById(replyTo));
75
+ }
76
+ catch (err) {
77
+ return refuse(`cannot be resolved to a chat (${String(err)})`);
78
+ }
79
+ const targetChatId = target.chatId ?? (currentChatId != null ? Number(currentChatId) : undefined);
80
+ if (targetChatId != null && replyChat.chatId === targetChatId)
81
+ return;
82
+ // A dialog addressed by user id: the reply must sit in the dialog with that user.
83
+ if (target.kind === 'dialog' && targetChatId == null && target.userId != null) {
84
+ let peer;
85
+ try {
86
+ peer = await classifyChat(api, replyChat);
87
+ }
88
+ catch (err) {
89
+ return refuse(`cannot be resolved to a chat (${String(err)})`);
90
+ }
91
+ if (peer.kind === 'dialog' && peer.chatId != null && peer.userId === target.userId)
92
+ return;
93
+ }
94
+ refuse(`belongs to another chat (${describeChat(replyChat)})`);
95
+ }
96
+ /** The author comes from the same GET /messages/{mid} as the chat; the bot id is remembered. */
97
+ async function assertBotMessage(action, api, messageId, authorId) {
98
+ let botUserId;
99
+ try {
100
+ botUserId = await api.getBotUserId();
101
+ }
102
+ catch (err) {
103
+ throw new ToolAuthorizationError(`MAX ${action} refused: cannot resolve the bot's own user id to check the author of message ${messageId} (${String(err)})`);
104
+ }
105
+ if (authorId != null && authorId === botUserId)
106
+ return;
107
+ throw new ToolAuthorizationError(`MAX ${action} refused: message ${messageId} was not sent by this bot. Outside the chat of the current turn ` +
108
+ 'only messages the bot sent itself may be edited or deleted (channels.max.actionScope).');
109
+ }
110
+ function deny(action, chat, reason) {
111
+ throw new ToolAuthorizationError(`MAX ${action} refused: ${chat} is outside the chats this conversation may act in (${reason}). ` +
112
+ 'Allowed: the current chat, chats admitted by channels.max dmPolicy/allowFrom/pairing and ' +
113
+ 'groupPolicy/groups/groupAllowFrom, or a request from the owner (channels.max.actionScope).');
114
+ }
115
+ /** Chat id of the current MAX turn (toolContext.currentChannelId is `max:<chatId>`). */
116
+ function readCurrentMaxChatId(ctx) {
117
+ const tool = ctx.toolContext;
118
+ if (!tool)
119
+ return undefined;
120
+ if (tool.currentChannelProvider && tool.currentChannelProvider !== 'max')
121
+ return undefined;
122
+ const raw = tool.currentChannelId?.trim() || tool.currentMessagingTarget?.trim();
123
+ if (!raw)
124
+ return undefined;
125
+ const id = raw.replace(/^max:/, '');
126
+ return /^-?\d+$/.test(id) ? id : undefined;
127
+ }
128
+ /**
129
+ * The current chat itself, or — from a DM turn — the requester's own dialog
130
+ * addressed as user:<id>.
131
+ */
132
+ function isCurrentChat(chat, currentChatId, requesterId) {
133
+ if (currentChatId && chat.chatId != null && String(chat.chatId) === currentChatId)
134
+ return true;
135
+ const currentIsDialog = currentChatId != null && !currentChatId.startsWith('-');
136
+ return (chat.kind === 'dialog' &&
137
+ currentIsDialog &&
138
+ chat.userId != null &&
139
+ requesterId === String(chat.userId));
140
+ }
141
+ function describeChat(chat) {
142
+ if (chat.kind === 'dialog') {
143
+ return chat.userId != null ? `the dialog with user ${chat.userId}` : `dialog ${chat.chatId}`;
144
+ }
145
+ return `chat ${chat.chatId}`;
146
+ }
147
+ /** Target resolution failures refuse the action (fail-closed). */
148
+ async function failClosed(action, target, resolve) {
149
+ try {
150
+ return await resolve();
151
+ }
152
+ catch (err) {
153
+ const what = 'messageId' in target ? `message ${target.messageId}` : `target ${target.to}`;
154
+ throw new ToolAuthorizationError(`MAX ${action} refused: cannot resolve the chat of ${what} to check channels.max.actionScope (${String(err)})`);
155
+ }
156
+ }
157
+ /**
158
+ * Where the action lands, without an API call for explicit targets:
159
+ * user:<id> is a dialog, a negative id a group or channel, a positive id is
160
+ * classified later. An existing message is looked up (GET /messages/{mid}),
161
+ * which also tells its author.
162
+ */
163
+ async function locateActionChat(api, target) {
164
+ if ('messageId' in target) {
165
+ const message = await api.getMessageById(target.messageId);
166
+ return { chat: chatOfMessage(message), authorId: message.sender?.user_id };
167
+ }
168
+ const to = target.to.trim().replace(/^max:/, '');
169
+ if (to.startsWith('user:')) {
170
+ const userId = Number(to.slice(5));
171
+ if (!Number.isFinite(userId))
172
+ throw new Error(`invalid MAX target: ${target.to}`);
173
+ return { chat: { kind: 'dialog', userId } };
174
+ }
175
+ const chatId = Number(to);
176
+ if (!to || !Number.isFinite(chatId))
177
+ throw new Error(`invalid MAX target: ${target.to}`);
178
+ return { chat: chatId < 0 ? { kind: 'group', chatId } : { kind: 'unknown', chatId } };
179
+ }
180
+ function chatOfMessage(message) {
181
+ const chatId = message.recipient?.chat_id;
182
+ if (chatId == null)
183
+ throw new Error('the message has no recipient chat');
184
+ const chatType = message.recipient?.chat_type;
185
+ if (chatType === 'chat' || chatType === 'channel')
186
+ return { kind: 'group', chatId };
187
+ if (chatType === 'dialog')
188
+ return { kind: 'dialog', chatId };
189
+ return chatId < 0 ? { kind: 'group', chatId } : { kind: 'unknown', chatId };
190
+ }
191
+ /**
192
+ * Settle a positive id (GET /chats: dialog or group; chat.not.found means a
193
+ * user id, which the send path retries as user_id) and a dialog's peer.
194
+ */
195
+ async function classifyChat(api, chat) {
196
+ if (chat.kind === 'group')
197
+ return chat;
198
+ if (chat.kind === 'dialog' && (chat.userId != null || chat.chatId == null))
199
+ return chat;
200
+ const chatId = chat.chatId;
201
+ try {
202
+ const info = await api.getChat(chatId);
203
+ if (info.type !== 'dialog')
204
+ return { kind: 'group', chatId };
205
+ return { kind: 'dialog', chatId, userId: info.dialog_with_user?.user_id };
206
+ }
207
+ catch (err) {
208
+ if (chat.kind === 'unknown' && err instanceof MaxApiError && isChatNotFound(err)) {
209
+ return { kind: 'dialog', userId: chatId };
210
+ }
211
+ throw err;
212
+ }
213
+ }
@@ -1,16 +1,76 @@
1
1
  /**
2
2
  * MAX channel message actions adapter — implements message tool actions
3
3
  */
4
+ import { createActionGate } from 'openclaw/plugin-sdk/channel-actions';
4
5
  import { normalizeMessagePresentation } from 'openclaw/plugin-sdk/interactive-runtime';
6
+ import { getAgentScopedMediaLocalRoots } from 'openclaw/plugin-sdk/media-local-roots';
5
7
  import { readStringParam } from 'openclaw/plugin-sdk/param-readers';
6
8
  import { jsonResult } from 'openclaw/plugin-sdk/tool-results';
7
9
  import { listMaxAccountIds, resolveMaxAccount } from './accounts.js';
8
- import { materializeMaxPresentation, MAX_TEXT_LIMIT, readMaxDeliveryPin } from './presentation.js';
10
+ import { assertMaxActionInScope } from './action-scope.js';
11
+ import { sanitizeMaxFileName } from './media-temp.js';
12
+ import { materializeMaxPresentation, readMaxDeliveryPin, resolveMaxTextChunkLimit, } from './presentation.js';
9
13
  import { getMaxRuntime } from './runtime.js';
10
- import { deleteMaxMessage, editMaxMessage, pinMaxMessage, readMaxChannelButtons, readMaxSendButtons, sendMaxContact, sendMaxLocation, sendMaxMediaGroup, sendMaxMediaMessage, sendMaxMessage, sendMaxSticker, unpinMaxMessage, } from './send.js';
14
+ import { deleteMaxMessage, describeMaxMediaSource, editMaxMessage, pinMaxMessage, readMaxChannelButtons, readMaxSendButtons, resolveMaxSendFlags, sendMaxContact, sendMaxLocation, sendMaxMediaGroup, sendMaxMediaMessage, sendMaxMessage, sendMaxSticker, unpinMaxMessage, } from './send.js';
11
15
  import { getLastStickerCode } from './sticker-cache.js';
12
16
  const providerId = 'max';
13
17
  const mediaSourceKeys = ['media', 'filePath', 'path', 'fileUrl', 'url', 'buffer', 'image'];
18
+ /**
19
+ * Params that carry a media path or URL, per action: core normalizes them for
20
+ * the sandbox and scopes outbound media access to them. `buffer` is base64
21
+ * content for core, not a path, so it is not declared.
22
+ */
23
+ const mediaSourceParams = mediaSourceKeys.filter((key) => key !== 'buffer');
24
+ /** `data:<type>;base64,<data>` — a data URL in `buffer`. */
25
+ const DATA_URL_RE = /^data:([^;,]*)(?:;[^,]*)?;base64,/i;
26
+ /**
27
+ * Inline content from `buffer` (base64 or a base64 data URL) with `filename`
28
+ * and `contentType` (aliases fileName, mimeType) of the same object. The size
29
+ * limit is checked before decoding, so an oversized payload is refused
30
+ * without allocating it.
31
+ */
32
+ function readInlineMedia(source, maxBytes) {
33
+ const raw = typeof source.buffer === 'string' ? source.buffer : undefined;
34
+ if (!raw?.trim())
35
+ return undefined;
36
+ const dataUrl = DATA_URL_RE.exec(raw);
37
+ const base64 = (dataUrl ? raw.slice(dataUrl[0].length) : raw).replace(/\s+/g, '');
38
+ if (!/^[A-Za-z0-9+/_-]*={0,2}$/.test(base64)) {
39
+ throw new Error('buffer is not valid base64 content');
40
+ }
41
+ const decodedBytes = Math.floor((base64.replace(/=+$/, '').length * 3) / 4);
42
+ if (decodedBytes > maxBytes) {
43
+ throw new Error(`buffer is ${decodedBytes} bytes, over the ${maxBytes}-byte media limit (mediaMaxMb)`);
44
+ }
45
+ const buffer = Buffer.from(base64, base64.includes('-') || base64.includes('_') ? 'base64url' : 'base64');
46
+ if (buffer.byteLength === 0)
47
+ throw new Error('buffer is empty');
48
+ const pick = (...keys) => keys
49
+ .map((key) => source[key])
50
+ .find((value) => typeof value === 'string' && value.trim() !== '')
51
+ ?.trim();
52
+ const contentType = pick('contentType', 'mimeType') ?? (dataUrl?.[1] || undefined);
53
+ return {
54
+ buffer,
55
+ fileName: sanitizeMaxFileName(pick('filename', 'fileName'), contentType),
56
+ contentType,
57
+ };
58
+ }
59
+ /**
60
+ * Local media access for an action: the roots and host reader core passed
61
+ * with the action; without them (direct plugin dispatch) the agent-scoped
62
+ * default roots. Local files are read only through this policy.
63
+ */
64
+ function resolveActionLocalMedia(ctx) {
65
+ if (ctx.mediaAccess || ctx.mediaLocalRoots?.length || ctx.mediaReadFile) {
66
+ return {
67
+ mediaAccess: ctx.mediaAccess,
68
+ mediaLocalRoots: ctx.mediaLocalRoots,
69
+ mediaReadFile: ctx.mediaReadFile,
70
+ };
71
+ }
72
+ return { mediaLocalRoots: getAgentScopedMediaLocalRoots(ctx.cfg, ctx.agentId ?? undefined) };
73
+ }
14
74
  function listEnabledAccounts(cfg) {
15
75
  return listMaxAccountIds(cfg)
16
76
  .map((accountId) => resolveMaxAccount({ cfg, accountId }))
@@ -25,32 +85,30 @@ function readTargetParam(params, required = true) {
25
85
  /**
26
86
  * Media sources in order: the direct media field first, then every
27
87
  * structured `attachments[]` item (one source per item). Duplicates dropped.
88
+ * A path or URL wins over `buffer` (core may fill both; the path goes through
89
+ * the guarded loader); `buffer` alone is sent as inline content.
28
90
  */
29
- function readMediaSources(params) {
91
+ function readMediaSources(params, maxBytes) {
30
92
  const sources = [];
31
93
  const add = (value) => {
32
94
  if (value && !sources.includes(value))
33
95
  sources.push(value);
34
96
  };
35
- for (const key of mediaSourceKeys) {
36
- const value = readStringParam(params, key, { trim: false });
37
- if (value) {
38
- add(value);
39
- break;
97
+ const readPath = (source) => {
98
+ for (const key of mediaSourceParams) {
99
+ const value = readStringParam(source, key, { trim: false });
100
+ if (value)
101
+ return value;
40
102
  }
41
- }
103
+ return undefined;
104
+ };
105
+ add(readPath(params) ?? readInlineMedia(params, maxBytes));
42
106
  if (Array.isArray(params.attachments)) {
43
107
  for (const item of params.attachments) {
44
108
  if (!item || typeof item !== 'object' || Array.isArray(item))
45
109
  continue;
46
110
  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
- }
111
+ add(readPath(attachment) ?? readInlineMedia(attachment, maxBytes));
54
112
  }
55
113
  }
56
114
  return sources;
@@ -77,7 +135,7 @@ async function sendMaxMediaSources(to, caption, sources, opts) {
77
135
  ...opts,
78
136
  onError: (err, failed) => {
79
137
  firstError ??= err;
80
- errors.push(`${failed.join(', ')}: ${String(err)}`);
138
+ errors.push(`${failed.map(describeMaxMediaSource).join(', ')}: ${String(err)}`);
81
139
  },
82
140
  });
83
141
  if (messageIds.length === 0 && firstError !== undefined)
@@ -119,6 +177,10 @@ function readLocationParams(params) {
119
177
  return undefined;
120
178
  return { latitude: lat, longitude: lng };
121
179
  }
180
+ /** Voice delivery of audio: core's `asVoice` (or its `audioAsVoice` alias), boolean or "true". */
181
+ function readAsVoiceParam(params) {
182
+ return [params.asVoice, params.audioAsVoice].some((value) => value === true || (typeof value === 'string' && value.trim() === 'true'));
183
+ }
122
184
  /** Contact card fields: contactId (MAX user id) and vcfPhone/phone next to the given name. */
123
185
  function readContactParams(params, name) {
124
186
  const contactId = params.contactId != null ? Number(params.contactId) : undefined;
@@ -129,19 +191,45 @@ function readContactParams(params, name) {
129
191
  vcfPhone: vcfPhone ?? undefined,
130
192
  };
131
193
  }
194
+ const MAX_MESSAGE_ACTIONS = [
195
+ 'send',
196
+ 'edit',
197
+ 'delete',
198
+ 'sticker',
199
+ 'sendAttachment',
200
+ 'pin',
201
+ 'unpin',
202
+ ];
132
203
  export const maxMessageActions = {
133
- describeMessageTool: ({ cfg }) => {
204
+ describeMessageTool: ({ cfg, accountId }) => {
134
205
  const accounts = listEnabledAccounts(cfg);
135
206
  if (accounts.length === 0) {
136
207
  return null;
137
208
  }
209
+ // channels.max.actions.<action>: false hides the action from the tool.
210
+ const gate = createActionGate(resolveMaxAccount({ cfg, accountId }).config.actions);
138
211
  return {
139
- actions: ['send', 'edit', 'delete', 'sticker', 'sendAttachment', 'pin', 'unpin'],
212
+ actions: MAX_MESSAGE_ACTIONS.filter((name) => gate(name)),
140
213
  // presentation → inline keyboard + MAX markdown (presentation.ts);
141
214
  // delivery-pin → PUT /chats/{chatId}/pin on the sent message.
142
215
  capabilities: ['presentation', 'delivery-pin'],
216
+ mediaSourceParams: { send: mediaSourceParams, sendAttachment: mediaSourceParams },
143
217
  };
144
218
  },
219
+ // A send with a presentation is delivered by core itself (outbound adapter
220
+ // sendPayload), never reaching handleAction: the action gate and scope are
221
+ // checked here first, then core delivers the payload unchanged. Any other
222
+ // send declines (null) and keeps the handleAction path, which checks them.
223
+ prepareSendPayload: async ({ ctx, to, payload, replyToId }) => {
224
+ if (!payload.presentation)
225
+ return null;
226
+ const account = resolveMaxAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
227
+ if (!createActionGate(account.config.actions)('send')) {
228
+ throw new Error('MAX action "send" is disabled by channels.max.actions');
229
+ }
230
+ await assertMaxActionInScope(ctx, account, { to, replyTo: replyToId });
231
+ return payload;
232
+ },
145
233
  extractToolSend: ({ args }) => {
146
234
  // Extract routing info for ALL actions (send, edit, delete, sticker)
147
235
  // Core uses extractToolSend for routing all message tool actions to plugin
@@ -162,13 +250,14 @@ export const maxMessageActions = {
162
250
  if (!to) {
163
251
  return null;
164
252
  }
165
- // Strip provider prefix (e.g. "max:188862440" → "188862440")
253
+ // Strip provider prefix (e.g. "max:123456789" → "123456789")
166
254
  if (to.startsWith('max:'))
167
255
  to = to.slice(4);
168
256
  const accountId = typeof args.accountId === 'string' ? args.accountId.trim() : undefined;
169
257
  return { to, accountId };
170
258
  },
171
- handleAction: async ({ action, params, cfg, accountId }) => {
259
+ handleAction: async (ctx) => {
260
+ const { action, params, cfg, accountId } = ctx;
172
261
  const account = resolveMaxAccount({
173
262
  cfg,
174
263
  accountId,
@@ -176,7 +265,15 @@ export const maxMessageActions = {
176
265
  if (!account.token) {
177
266
  throw new Error('MAX bot token not configured');
178
267
  }
179
- // Strip provider prefix from target (e.g. "max:188862440" → "188862440")
268
+ if (!createActionGate(account.config.actions)(action)) {
269
+ throw new Error(`MAX action "${action}" is disabled by channels.max.actions`);
270
+ }
271
+ // Notification and link-preview options of sends: core's `silent` flag
272
+ // beats the account defaults (channels.max.notify / disableLinkPreview).
273
+ const sendFlags = resolveMaxSendFlags(account.config, {
274
+ silent: typeof params.silent === 'boolean' ? params.silent : undefined,
275
+ });
276
+ // Strip provider prefix from target (e.g. "max:123456789" → "123456789")
180
277
  const stripPrefix = (val) => {
181
278
  if (!val)
182
279
  return val;
@@ -184,6 +281,10 @@ export const maxMessageActions = {
184
281
  };
185
282
  if (action === 'send') {
186
283
  const to = stripPrefix(readTargetParam(params));
284
+ await assertMaxActionInScope(ctx, account, {
285
+ to,
286
+ replyTo: readStringParam(params, 'replyTo'),
287
+ });
187
288
  const presentation = normalizeMessagePresentation(params.presentation);
188
289
  const pin = readMaxDeliveryPin(params.delivery, params.pin);
189
290
  const content = readStringParam(params, 'message', {
@@ -233,14 +334,16 @@ export const maxMessageActions = {
233
334
  const rendered = await materializeMaxPresentation({ text: content, presentation });
234
335
  const renderedButtons = readMaxChannelButtons(rendered.channelData);
235
336
  const text = rendered.text ?? '';
236
- const chunks = text.length > MAX_TEXT_LIMIT
237
- ? getMaxRuntime().channel.text.chunkMarkdownText(text, MAX_TEXT_LIMIT)
337
+ const chunkLimit = resolveMaxTextChunkLimit(cfg, account.accountId);
338
+ const chunks = text.length > chunkLimit
339
+ ? getMaxRuntime().channel.text.chunkMarkdownText(text, chunkLimit)
238
340
  : [text];
239
341
  let firstMessageId = '';
240
342
  let firstChatType;
241
343
  for (let index = 0; index < chunks.length; index += 1) {
242
344
  const sent = await sendMaxMessage(to, chunks[index], {
243
345
  token: account.token,
346
+ ...sendFlags,
244
347
  replyToMessageId: index === 0 ? (readStringParam(params, 'replyTo') ?? undefined) : undefined,
245
348
  format: 'markdown',
246
349
  buttons: index === chunks.length - 1 ? renderedButtons : undefined,
@@ -267,6 +370,7 @@ export const maxMessageActions = {
267
370
  if (firstCode) {
268
371
  const result = await sendMaxSticker(to, firstCode, {
269
372
  token: account.token,
373
+ ...sendFlags,
270
374
  replyToMessageId: replyTo ?? undefined,
271
375
  });
272
376
  return withPin(result.messageId, result.raw.message?.recipient?.chat_type);
@@ -277,6 +381,7 @@ export const maxMessageActions = {
277
381
  if (location) {
278
382
  const result = await sendMaxLocation(to, location, content || undefined, {
279
383
  token: account.token,
384
+ ...sendFlags,
280
385
  replyToMessageId: replyTo ?? undefined,
281
386
  format: 'markdown',
282
387
  });
@@ -287,25 +392,32 @@ export const maxMessageActions = {
287
392
  if (contactName) {
288
393
  const result = await sendMaxContact(to, readContactParams(params, contactName), {
289
394
  token: account.token,
395
+ ...sendFlags,
290
396
  replyToMessageId: replyTo ?? undefined,
291
397
  });
292
398
  return withPin(result.messageId, result.raw.message?.recipient?.chat_type);
293
399
  }
294
400
  // Media: direct media fields and every structured attachments[] item.
295
- const mediaSources = readMediaSources(params);
401
+ const mediaMaxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
402
+ const mediaSources = readMediaSources(params, mediaMaxBytes);
296
403
  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.
404
+ // Local paths only under the allowed roots (resolveActionLocalMedia);
405
+ // public https image links go by URL, other remote media is
406
+ // downloaded through the SSRF-guarded fetcher and uploaded.
299
407
  const result = await sendMaxMediaSources(to, content, mediaSources, {
300
408
  token: account.token,
409
+ ...sendFlags,
301
410
  replyToMessageId: replyTo ?? undefined,
302
411
  format: 'markdown',
303
- mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
412
+ mediaMaxBytes,
413
+ localMedia: resolveActionLocalMedia(ctx),
414
+ asVoice: readAsVoiceParam(params),
304
415
  });
305
416
  return withPin(result.messageId, result.chatType, mediaResultExtra(result));
306
417
  }
307
418
  const result = await sendMaxMessage(to, content, {
308
419
  token: account.token,
420
+ ...sendFlags,
309
421
  replyToMessageId: replyTo ?? undefined,
310
422
  format: 'markdown',
311
423
  buttons,
@@ -314,6 +426,7 @@ export const maxMessageActions = {
314
426
  }
315
427
  if (action === 'edit') {
316
428
  const messageId = readStringParam(params, 'messageId', { required: true });
429
+ await assertMaxActionInScope(ctx, account, { messageId });
317
430
  const text = readStringParam(params, 'message', {
318
431
  required: true,
319
432
  allowEmpty: true,
@@ -326,6 +439,7 @@ export const maxMessageActions = {
326
439
  }
327
440
  if (action === 'delete') {
328
441
  const messageId = readStringParam(params, 'messageId', { required: true });
442
+ await assertMaxActionInScope(ctx, account, { messageId });
329
443
  await deleteMaxMessage(messageId, {
330
444
  token: account.token,
331
445
  });
@@ -334,6 +448,7 @@ export const maxMessageActions = {
334
448
  if (action === 'pin') {
335
449
  const to = stripPrefix(readTargetParam(params));
336
450
  const messageId = readStringParam(params, 'messageId', { required: true });
451
+ await assertMaxActionInScope(ctx, account, { to });
337
452
  const notifyParam = params.notify;
338
453
  const pinned = await pinMaxMessage(to, messageId, {
339
454
  token: account.token,
@@ -346,11 +461,16 @@ export const maxMessageActions = {
346
461
  }
347
462
  if (action === 'unpin') {
348
463
  const to = stripPrefix(readTargetParam(params));
464
+ await assertMaxActionInScope(ctx, account, { to });
349
465
  await unpinMaxMessage(to, { token: account.token });
350
466
  return jsonResult({ ok: true, to, pinned: false });
351
467
  }
352
468
  if (action === 'sticker') {
353
469
  const to = stripPrefix(readTargetParam(params));
470
+ await assertMaxActionInScope(ctx, account, {
471
+ to,
472
+ replyTo: readStringParam(params, 'replyTo'),
473
+ });
354
474
  // stickerId may come as string or string[] from message tool schema
355
475
  const rawStickerId = params.stickerId;
356
476
  let stickerCode = Array.isArray(rawStickerId)
@@ -366,6 +486,7 @@ export const maxMessageActions = {
366
486
  const replyTo = readStringParam(params, 'replyTo');
367
487
  const result = await sendMaxSticker(to, stickerCode, {
368
488
  token: account.token,
489
+ ...sendFlags,
369
490
  replyToMessageId: replyTo ?? undefined,
370
491
  });
371
492
  return jsonResult({ ok: true, to, messageId: result.messageId });
@@ -373,15 +494,20 @@ export const maxMessageActions = {
373
494
  if (action === 'sendAttachment') {
374
495
  const to = stripPrefix(readTargetParam(params));
375
496
  const replyTo = readStringParam(params, 'replyTo');
497
+ await assertMaxActionInScope(ctx, account, { to, replyTo });
376
498
  const caption = readStringParam(params, 'message') ?? readStringParam(params, 'caption') ?? '';
377
499
  const attachType = readStringParam(params, 'type') ?? readStringParam(params, 'attachmentType') ?? '';
378
- const mediaSources = readMediaSources(params);
500
+ const mediaMaxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
501
+ const mediaSources = readMediaSources(params, mediaMaxBytes);
379
502
  if (mediaSources.length) {
380
503
  const result = await sendMaxMediaSources(to, caption, mediaSources, {
381
504
  token: account.token,
505
+ ...sendFlags,
382
506
  replyToMessageId: replyTo ?? undefined,
383
507
  format: 'markdown',
384
- mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
508
+ mediaMaxBytes,
509
+ localMedia: resolveActionLocalMedia(ctx),
510
+ asVoice: readAsVoiceParam(params),
385
511
  });
386
512
  return jsonResult({
387
513
  ok: true,
@@ -399,6 +525,7 @@ export const maxMessageActions = {
399
525
  if (location) {
400
526
  const result = await sendMaxLocation(to, location, caption || undefined, {
401
527
  token: account.token,
528
+ ...sendFlags,
402
529
  replyToMessageId: replyTo ?? undefined,
403
530
  });
404
531
  return jsonResult({ ok: true, to, messageId: result.messageId });
@@ -410,11 +537,12 @@ export const maxMessageActions = {
410
537
  const contactName = readStringParam(params, 'contactName') ?? readStringParam(params, 'name') ?? 'Unknown';
411
538
  const result = await sendMaxContact(to, readContactParams(params, contactName), {
412
539
  token: account.token,
540
+ ...sendFlags,
413
541
  replyToMessageId: replyTo ?? undefined,
414
542
  });
415
543
  return jsonResult({ ok: true, to, messageId: result.messageId });
416
544
  }
417
- throw new Error("sendAttachment: unknown type. Use media/filePath for files, or type='location' / type='contact'");
545
+ throw new Error("sendAttachment: unknown type. Use media/filePath (or base64 buffer with filename) for files, or type='location' / type='contact'");
418
546
  }
419
547
  throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
420
548
  },