@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.
- 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-policy.js +19 -6
- 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 +983 -8
- package/package.json +17 -4
- 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-policy.ts +27 -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,274 @@
|
|
|
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
|
+
|
|
9
|
+
import { ToolAuthorizationError } from 'openclaw/plugin-sdk/channel-actions';
|
|
10
|
+
import type { ChannelMessageActionContext } from 'openclaw/plugin-sdk/channel-contract';
|
|
11
|
+
import { normalizeAccountId } from 'openclaw/plugin-sdk/core';
|
|
12
|
+
|
|
13
|
+
import { admitMaxDmUser, admitMaxGroupMessage, type MaxAdmission } from './access-policy.js';
|
|
14
|
+
import type { ResolvedMaxAccount } from './accounts.js';
|
|
15
|
+
import { MaxApi, MaxApiError } from './api.js';
|
|
16
|
+
import { isChatNotFound } from './send.js';
|
|
17
|
+
import type { MaxMessage } from './types.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* - `admitted` (default): the current chat of the turn, an owner request
|
|
21
|
+
* (senderIsOwner=true), or a chat the inbound policy admits (DM policy with
|
|
22
|
+
* allowFrom and pairing, group policy with its chat and sender allowlists);
|
|
23
|
+
* - `current`: a non-owner turn acts only in its own chat;
|
|
24
|
+
* - `off`: no plugin check (0.7.x behavior; core policy only).
|
|
25
|
+
*/
|
|
26
|
+
export type MaxActionScope = 'admitted' | 'current' | 'off';
|
|
27
|
+
|
|
28
|
+
/** The chat an action lands in. */
|
|
29
|
+
type MaxSettledChat =
|
|
30
|
+
{ kind: 'group'; chatId: number } | { kind: 'dialog'; chatId?: number; userId?: number };
|
|
31
|
+
|
|
32
|
+
/** As located before any GET /chats: `unknown` is a positive id not yet classified. */
|
|
33
|
+
type MaxActionChat = MaxSettledChat | { kind: 'unknown'; chatId: number };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What the action addresses: an explicit target (with the message a send
|
|
37
|
+
* replies to, if any) or an existing message.
|
|
38
|
+
*/
|
|
39
|
+
export type MaxActionTarget = { to: string; replyTo?: string | null } | { messageId: string };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Throws ToolAuthorizationError (a tool error the agent sees) when the action
|
|
43
|
+
* may not run in its chat under channels.max.actionScope. Only a call core
|
|
44
|
+
* marks senderIsOwner=true is not scoped (CLI `openclaw message`, an admin's
|
|
45
|
+
* Control UI chat, the owner's turn). Every other call, the flag missing
|
|
46
|
+
* included (heartbeat, subagents, scheduled runs without message authority),
|
|
47
|
+
* is held to the chats the inbound policy admits.
|
|
48
|
+
*/
|
|
49
|
+
export async function assertMaxActionInScope(
|
|
50
|
+
ctx: ChannelMessageActionContext,
|
|
51
|
+
account: ResolvedMaxAccount,
|
|
52
|
+
target: MaxActionTarget,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const scope: MaxActionScope = account.config.actionScope ?? 'admitted';
|
|
55
|
+
if (scope === 'off' || ctx.senderIsOwner === true) return;
|
|
56
|
+
|
|
57
|
+
const turnChatId = readCurrentMaxChatId(ctx);
|
|
58
|
+
const requesterId = ctx.requesterSenderId?.trim() || undefined;
|
|
59
|
+
// The turn's chat is current only for the account the turn came in on:
|
|
60
|
+
// another bot of the same group is held to its own policy.
|
|
61
|
+
const turnAccountId = ctx.requesterAccountId?.trim();
|
|
62
|
+
const currentChatId =
|
|
63
|
+
!turnAccountId || normalizeAccountId(turnAccountId) === account.accountId
|
|
64
|
+
? turnChatId
|
|
65
|
+
: undefined;
|
|
66
|
+
|
|
67
|
+
const api = new MaxApi({ token: account.token });
|
|
68
|
+
const { chat: located, authorId } = await failClosed(ctx.action, target, () =>
|
|
69
|
+
locateActionChat(api, target),
|
|
70
|
+
);
|
|
71
|
+
const replyTo = 'to' in target ? target.replyTo?.trim() || undefined : undefined;
|
|
72
|
+
if (isCurrentChat(located, currentChatId, requesterId)) {
|
|
73
|
+
if (replyTo) await assertReplyInChat(ctx, api, replyTo, located, currentChatId);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (scope === 'current') {
|
|
78
|
+
deny(
|
|
79
|
+
ctx.action,
|
|
80
|
+
describeChat(located),
|
|
81
|
+
'actionScope="current" allows only the chat of the current turn',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const chat = await failClosed(ctx.action, target, () => classifyChat(api, located));
|
|
85
|
+
// A group admits the action only for a requester its sender allowlist admits.
|
|
86
|
+
const admission: MaxAdmission =
|
|
87
|
+
chat.kind === 'group'
|
|
88
|
+
? admitMaxGroupMessage(account, ctx.cfg, chat.chatId, requesterId)
|
|
89
|
+
: await admitMaxDmUser(account, chat.userId);
|
|
90
|
+
if (!admission.admitted) deny(ctx.action, describeChat(chat), admission.reason);
|
|
91
|
+
// Outside the chat of the turn, edit and delete reach only the bot's own
|
|
92
|
+
// messages: what others wrote in an admitted chat is not the agent's to change.
|
|
93
|
+
if ('messageId' in target) await assertBotMessage(ctx.action, api, target.messageId, authorId);
|
|
94
|
+
if (replyTo) await assertReplyInChat(ctx, api, replyTo, chat);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A reply quotes its message in the chat it is sent to: that message must
|
|
99
|
+
* belong to the same chat, or the quote would carry another chat's text
|
|
100
|
+
* there. GET /messages/{mid} runs only for a reply; the message that started
|
|
101
|
+
* the turn needs no lookup in its own chat.
|
|
102
|
+
*/
|
|
103
|
+
async function assertReplyInChat(
|
|
104
|
+
ctx: ChannelMessageActionContext,
|
|
105
|
+
api: MaxApi,
|
|
106
|
+
replyTo: string,
|
|
107
|
+
target: MaxActionChat,
|
|
108
|
+
currentChatId?: string,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const turnMessageId = ctx.toolContext?.currentMessageId;
|
|
111
|
+
if (currentChatId && turnMessageId != null && String(turnMessageId).trim() === replyTo) return;
|
|
112
|
+
const refuse = (why: string): never => {
|
|
113
|
+
throw new ToolAuthorizationError(
|
|
114
|
+
`MAX ${ctx.action} refused: replyTo message ${replyTo} ${why}; a reply may quote only a message ` +
|
|
115
|
+
`of the chat it is sent to, here ${describeChat(target)} (channels.max.actionScope).`,
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
let replyChat: MaxActionChat;
|
|
119
|
+
try {
|
|
120
|
+
replyChat = chatOfMessage(await api.getMessageById(replyTo));
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return refuse(`cannot be resolved to a chat (${String(err)})`);
|
|
123
|
+
}
|
|
124
|
+
const targetChatId = target.chatId ?? (currentChatId != null ? Number(currentChatId) : undefined);
|
|
125
|
+
if (targetChatId != null && replyChat.chatId === targetChatId) return;
|
|
126
|
+
// A dialog addressed by user id: the reply must sit in the dialog with that user.
|
|
127
|
+
if (target.kind === 'dialog' && targetChatId == null && target.userId != null) {
|
|
128
|
+
let peer: MaxSettledChat;
|
|
129
|
+
try {
|
|
130
|
+
peer = await classifyChat(api, replyChat);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return refuse(`cannot be resolved to a chat (${String(err)})`);
|
|
133
|
+
}
|
|
134
|
+
if (peer.kind === 'dialog' && peer.chatId != null && peer.userId === target.userId) return;
|
|
135
|
+
}
|
|
136
|
+
refuse(`belongs to another chat (${describeChat(replyChat)})`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The author comes from the same GET /messages/{mid} as the chat; the bot id is remembered. */
|
|
140
|
+
async function assertBotMessage(
|
|
141
|
+
action: string,
|
|
142
|
+
api: MaxApi,
|
|
143
|
+
messageId: string,
|
|
144
|
+
authorId: number | undefined,
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
let botUserId: number;
|
|
147
|
+
try {
|
|
148
|
+
botUserId = await api.getBotUserId();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
throw new ToolAuthorizationError(
|
|
151
|
+
`MAX ${action} refused: cannot resolve the bot's own user id to check the author of message ${messageId} (${String(err)})`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (authorId != null && authorId === botUserId) return;
|
|
155
|
+
throw new ToolAuthorizationError(
|
|
156
|
+
`MAX ${action} refused: message ${messageId} was not sent by this bot. Outside the chat of the current turn ` +
|
|
157
|
+
'only messages the bot sent itself may be edited or deleted (channels.max.actionScope).',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function deny(action: string, chat: string, reason: string): never {
|
|
162
|
+
throw new ToolAuthorizationError(
|
|
163
|
+
`MAX ${action} refused: ${chat} is outside the chats this conversation may act in (${reason}). ` +
|
|
164
|
+
'Allowed: the current chat, chats admitted by channels.max dmPolicy/allowFrom/pairing and ' +
|
|
165
|
+
'groupPolicy/groups/groupAllowFrom, or a request from the owner (channels.max.actionScope).',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Chat id of the current MAX turn (toolContext.currentChannelId is `max:<chatId>`). */
|
|
170
|
+
function readCurrentMaxChatId(ctx: ChannelMessageActionContext): string | undefined {
|
|
171
|
+
const tool = ctx.toolContext;
|
|
172
|
+
if (!tool) return undefined;
|
|
173
|
+
if (tool.currentChannelProvider && tool.currentChannelProvider !== 'max') return undefined;
|
|
174
|
+
const raw = tool.currentChannelId?.trim() || tool.currentMessagingTarget?.trim();
|
|
175
|
+
if (!raw) return undefined;
|
|
176
|
+
const id = raw.replace(/^max:/, '');
|
|
177
|
+
return /^-?\d+$/.test(id) ? id : undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The current chat itself, or — from a DM turn — the requester's own dialog
|
|
182
|
+
* addressed as user:<id>.
|
|
183
|
+
*/
|
|
184
|
+
function isCurrentChat(
|
|
185
|
+
chat: MaxActionChat,
|
|
186
|
+
currentChatId: string | undefined,
|
|
187
|
+
requesterId: string | undefined,
|
|
188
|
+
): boolean {
|
|
189
|
+
if (currentChatId && chat.chatId != null && String(chat.chatId) === currentChatId) return true;
|
|
190
|
+
const currentIsDialog = currentChatId != null && !currentChatId.startsWith('-');
|
|
191
|
+
return (
|
|
192
|
+
chat.kind === 'dialog' &&
|
|
193
|
+
currentIsDialog &&
|
|
194
|
+
chat.userId != null &&
|
|
195
|
+
requesterId === String(chat.userId)
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function describeChat(chat: MaxActionChat): string {
|
|
200
|
+
if (chat.kind === 'dialog') {
|
|
201
|
+
return chat.userId != null ? `the dialog with user ${chat.userId}` : `dialog ${chat.chatId}`;
|
|
202
|
+
}
|
|
203
|
+
return `chat ${chat.chatId}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Target resolution failures refuse the action (fail-closed). */
|
|
207
|
+
async function failClosed<T>(
|
|
208
|
+
action: string,
|
|
209
|
+
target: MaxActionTarget,
|
|
210
|
+
resolve: () => Promise<T>,
|
|
211
|
+
): Promise<T> {
|
|
212
|
+
try {
|
|
213
|
+
return await resolve();
|
|
214
|
+
} catch (err) {
|
|
215
|
+
const what = 'messageId' in target ? `message ${target.messageId}` : `target ${target.to}`;
|
|
216
|
+
throw new ToolAuthorizationError(
|
|
217
|
+
`MAX ${action} refused: cannot resolve the chat of ${what} to check channels.max.actionScope (${String(err)})`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Where the action lands, without an API call for explicit targets:
|
|
224
|
+
* user:<id> is a dialog, a negative id a group or channel, a positive id is
|
|
225
|
+
* classified later. An existing message is looked up (GET /messages/{mid}),
|
|
226
|
+
* which also tells its author.
|
|
227
|
+
*/
|
|
228
|
+
async function locateActionChat(
|
|
229
|
+
api: MaxApi,
|
|
230
|
+
target: MaxActionTarget,
|
|
231
|
+
): Promise<{ chat: MaxActionChat; authorId?: number }> {
|
|
232
|
+
if ('messageId' in target) {
|
|
233
|
+
const message = await api.getMessageById(target.messageId);
|
|
234
|
+
return { chat: chatOfMessage(message), authorId: message.sender?.user_id };
|
|
235
|
+
}
|
|
236
|
+
const to = target.to.trim().replace(/^max:/, '');
|
|
237
|
+
if (to.startsWith('user:')) {
|
|
238
|
+
const userId = Number(to.slice(5));
|
|
239
|
+
if (!Number.isFinite(userId)) throw new Error(`invalid MAX target: ${target.to}`);
|
|
240
|
+
return { chat: { kind: 'dialog', userId } };
|
|
241
|
+
}
|
|
242
|
+
const chatId = Number(to);
|
|
243
|
+
if (!to || !Number.isFinite(chatId)) throw new Error(`invalid MAX target: ${target.to}`);
|
|
244
|
+
return { chat: chatId < 0 ? { kind: 'group', chatId } : { kind: 'unknown', chatId } };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function chatOfMessage(message: MaxMessage): MaxActionChat {
|
|
248
|
+
const chatId = message.recipient?.chat_id;
|
|
249
|
+
if (chatId == null) throw new Error('the message has no recipient chat');
|
|
250
|
+
const chatType = message.recipient?.chat_type;
|
|
251
|
+
if (chatType === 'chat' || chatType === 'channel') return { kind: 'group', chatId };
|
|
252
|
+
if (chatType === 'dialog') return { kind: 'dialog', chatId };
|
|
253
|
+
return chatId < 0 ? { kind: 'group', chatId } : { kind: 'unknown', chatId };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Settle a positive id (GET /chats: dialog or group; chat.not.found means a
|
|
258
|
+
* user id, which the send path retries as user_id) and a dialog's peer.
|
|
259
|
+
*/
|
|
260
|
+
async function classifyChat(api: MaxApi, chat: MaxActionChat): Promise<MaxSettledChat> {
|
|
261
|
+
if (chat.kind === 'group') return chat;
|
|
262
|
+
if (chat.kind === 'dialog' && (chat.userId != null || chat.chatId == null)) return chat;
|
|
263
|
+
const chatId = chat.chatId as number;
|
|
264
|
+
try {
|
|
265
|
+
const info = await api.getChat(chatId);
|
|
266
|
+
if (info.type !== 'dialog') return { kind: 'group', chatId };
|
|
267
|
+
return { kind: 'dialog', chatId, userId: info.dialog_with_user?.user_id };
|
|
268
|
+
} catch (err) {
|
|
269
|
+
if (chat.kind === 'unknown' && err instanceof MaxApiError && isChatNotFound(err)) {
|
|
270
|
+
return { kind: 'dialog', userId: chatId };
|
|
271
|
+
}
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
}
|