@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.
- package/LICENSE +2 -0
- package/README.md +412 -344
- package/README_EN.md +492 -0
- package/SKILL.md +45 -37
- package/assets/icon.png +0 -0
- package/dist/secret-contract-api.js +6 -0
- package/dist/src/access-policy.js +123 -0
- package/dist/src/accounts.js +170 -23
- package/dist/src/action-scope.js +213 -0
- package/dist/src/actions.js +159 -31
- package/dist/src/api.js +99 -83
- package/dist/src/callbacks.js +27 -7
- package/dist/src/channel-agent-prompt.js +8 -19
- package/dist/src/channel-config.js +25 -3
- package/dist/src/channel-lifecycle.js +28 -5
- package/dist/src/channel-outbound.js +43 -9
- package/dist/src/channel.js +16 -0
- package/dist/src/command-menu.js +199 -0
- package/dist/src/config-schema.js +106 -10
- package/dist/src/deliver.js +144 -48
- package/dist/src/dispatch.js +12 -11
- package/dist/src/gateway-drain.js +40 -0
- package/dist/src/group-mention.js +129 -0
- package/dist/src/inbound-attachments.js +162 -20
- package/dist/src/inbound.js +388 -134
- package/dist/src/media-temp.js +31 -20
- package/dist/src/network.js +216 -0
- package/dist/src/onboarding.js +4 -2
- package/dist/src/polling.js +180 -12
- package/dist/src/presentation.js +11 -0
- package/dist/src/progress-draft.js +168 -0
- package/dist/src/runtime.js +15 -0
- package/dist/src/secret-contract.js +149 -0
- package/dist/src/send.js +250 -46
- package/dist/src/stream-draft.js +83 -41
- package/dist/src/turn-adoption.js +24 -0
- package/dist/src/typing.js +55 -0
- package/dist/src/update-journal.js +267 -0
- package/dist/src/webhook-queue.js +151 -26
- package/dist/src/webhook-runner.js +61 -9
- package/dist/src/webhook.js +34 -15
- package/openclaw.plugin.json +986 -13
- package/package.json +25 -5
- package/src/access-policy.ts +154 -0
- package/src/accounts.ts +260 -25
- package/src/action-scope.ts +274 -0
- package/src/actions.ts +188 -35
- package/src/api.ts +158 -101
- package/src/callbacks.ts +26 -7
- package/src/channel-agent-prompt.ts +8 -19
- package/src/channel-config.ts +32 -3
- package/src/channel-lifecycle.ts +33 -6
- package/src/channel-outbound.ts +50 -7
- package/src/channel.ts +16 -0
- package/src/command-menu.ts +277 -0
- package/src/config-schema.ts +111 -10
- package/src/deliver.ts +174 -55
- package/src/dispatch.ts +12 -11
- package/src/gateway-drain.ts +45 -0
- package/src/group-mention.ts +191 -0
- package/src/inbound-attachments.ts +173 -22
- package/src/inbound.ts +443 -130
- package/src/media-temp.ts +35 -23
- package/src/network.ts +233 -0
- package/src/onboarding.ts +4 -2
- package/src/polling.ts +221 -15
- package/src/presentation.ts +14 -1
- package/src/progress-draft.ts +226 -0
- package/src/runtime.ts +23 -0
- package/src/secret-contract.ts +183 -0
- package/src/send.ts +346 -59
- package/src/stream-draft.ts +102 -41
- package/src/turn-adoption.ts +33 -0
- package/src/types.ts +9 -3
- package/src/typing.ts +77 -0
- package/src/update-journal.ts +302 -0
- package/src/webhook-queue.ts +205 -33
- package/src/webhook-runner.ts +65 -8
- package/src/webhook.ts +39 -18
- package/scripts/check-cycles.mjs +0 -38
- package/scripts/test-api.mjs +0 -169
- package/scripts/test-send.mjs +0 -162
- 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
|
+
}
|
package/dist/src/actions.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
|
|
237
|
-
|
|
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
|
|
401
|
+
const mediaMaxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
|
|
402
|
+
const mediaSources = readMediaSources(params, mediaMaxBytes);
|
|
296
403
|
if (mediaSources.length) {
|
|
297
|
-
// Local
|
|
298
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
},
|