clawgram 2.21.0 → 2.22.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/README.md +34 -8
- package/dist/channel.js +290 -57
- package/dist/chat-info.js +28 -22
- package/dist/cli-core.js +109 -20
- package/dist/expiring-map.js +96 -0
- package/dist/gramjs-client.js +104 -29
- package/dist/group-reply-address.js +6 -16
- package/dist/group-visible-reply-guard.js +12 -32
- package/dist/helpers.js +50 -5
- package/dist/history.js +54 -22
- package/dist/html-render.js +44 -1
- package/dist/joins.js +8 -6
- package/dist/manage.js +9 -15
- package/dist/media.js +15 -16
- package/dist/normalize.js +6 -1
- package/dist/proxy-config.js +2 -4
- package/dist/reactions.js +3 -12
- package/dist/secret-refs.js +1 -0
- package/dist/send-scope.js +97 -0
- package/dist/silent-reaction.js +9 -5
- package/dist/state-dir.js +24 -0
- package/dist/system-notice.js +49 -4
- package/dist/update-config.js +139 -33
- package/dist/util.js +36 -0
- package/npm-shrinkwrap.json +4531 -0
- package/openclaw.plugin.json +39 -5
- package/package.json +12 -4
package/dist/channel.js
CHANGED
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.createChannelPlugin = exports.CORE_ACTION_SYNONYMS = void 0;
|
|
7
|
+
exports.canonicalAction = canonicalAction;
|
|
7
8
|
const core_1 = require("openclaw/plugin-sdk/core");
|
|
8
9
|
const node_os_1 = __importDefault(require("node:os"));
|
|
9
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
@@ -20,7 +21,10 @@ const INBOUND_MEDIA_MAX_BYTES = 25 * 1024 * 1024;
|
|
|
20
21
|
* that a chat full of images does not silently become a copy of itself in the
|
|
21
22
|
* temp directory.
|
|
22
23
|
*/
|
|
23
|
-
|
|
24
|
+
// Час, а не сутки: `fetch-media` существует ради «прочитать и переслать», и
|
|
25
|
+
// файл нужен ровно на время хода. Сутки означали сутки чужой личной переписки
|
|
26
|
+
// на диске (A5-13).
|
|
27
|
+
const FETCHED_MEDIA_TTL_MS = 60 * 60 * 1000;
|
|
24
28
|
/**
|
|
25
29
|
* What this channel promises the Gateway.
|
|
26
30
|
*
|
|
@@ -67,11 +71,13 @@ const constants_1 = require("./constants");
|
|
|
67
71
|
const gramjs_client_1 = require("./gramjs-client");
|
|
68
72
|
const normalize_1 = require("./normalize");
|
|
69
73
|
const history_1 = require("./history");
|
|
74
|
+
const send_scope_1 = require("./send-scope");
|
|
70
75
|
const joins_1 = require("./joins");
|
|
71
76
|
const reactions_1 = require("./reactions");
|
|
72
77
|
const manage_1 = require("./manage");
|
|
73
78
|
const silent_reaction_1 = require("./silent-reaction");
|
|
74
79
|
const system_notice_1 = require("./system-notice");
|
|
80
|
+
const state_dir_1 = require("./state-dir");
|
|
75
81
|
const chat_info_1 = require("./chat-info");
|
|
76
82
|
const topics_1 = require("./topics");
|
|
77
83
|
const dialogs_1 = require("./dialogs");
|
|
@@ -159,12 +165,71 @@ function readAccountReadChats(account) {
|
|
|
159
165
|
function resolveAccountReadChats(cfg, accountId) {
|
|
160
166
|
return readAccountReadChats(cfg?.channels?.["clawgram"]?.accounts?.[accountId]);
|
|
161
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Outbound scope as configured. Handed to `isChatSendable` raw: an absent
|
|
170
|
+
* value means "unrestricted" and an empty list means "deny", and only the
|
|
171
|
+
* raw value tells those apart — same shape as `readChats`.
|
|
172
|
+
*/
|
|
173
|
+
/**
|
|
174
|
+
* Хэндл в `allowFrom` — обещание, которое Telegram не держит.
|
|
175
|
+
*
|
|
176
|
+
* Запись `@username` утверждает не про человека, а про хэндл: хэндл можно
|
|
177
|
+
* освободить, и тогда его берёт кто угодно — запись начинает пускать
|
|
178
|
+
* постороннего, ничего об этом не сказав. Числовой id так не переходит из рук
|
|
179
|
+
* в руки. Отказываться от хэндлов нельзя (люди пишут ими, и конфиг у многих
|
|
180
|
+
* уже такой), но молчать об этом тоже не годится — поэтому предупреждение
|
|
181
|
+
* один раз при старте аккаунта (находка A5-16).
|
|
182
|
+
*/
|
|
183
|
+
function warnAboutHandleAllowlistEntries(cfg, accountId) {
|
|
184
|
+
const account = cfg?.channels?.["clawgram"]?.accounts?.[accountId];
|
|
185
|
+
const entries = Array.isArray(account?.allowFrom) ? account.allowFrom : [];
|
|
186
|
+
const handles = entries
|
|
187
|
+
.map((entry) => String(entry ?? "").trim())
|
|
188
|
+
.filter((entry) => entry.startsWith("@"));
|
|
189
|
+
if (handles.length === 0) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
actionLog.warn("clawgram allowFrom names handles, not ids", {
|
|
193
|
+
accountId,
|
|
194
|
+
// Сами хэндлы — это про людей: в лог уходит только их число.
|
|
195
|
+
handleEntries: handles.length,
|
|
196
|
+
why: "a released handle can be taken by someone else; numeric ids do not change hands",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function resolveAccountSendChats(cfg, accountId) {
|
|
200
|
+
return cfg?.channels?.["clawgram"]?.accounts?.[accountId]?.sendChats;
|
|
201
|
+
}
|
|
202
|
+
/** One refusal for every outbound action, so the three read the same. */
|
|
203
|
+
function refuseOutboundOutsideScope(action, accountId, target) {
|
|
204
|
+
const reason = (0, send_scope_1.isPhoneNumberTarget)(target) ? "phone-number target" : "chat outside send scope";
|
|
205
|
+
actionLog.warn(`clawgram ${action} refused: ${reason}`, { accountId, target });
|
|
206
|
+
throw new Error(`clawgram: not-allowed-chat ${target}`);
|
|
207
|
+
}
|
|
162
208
|
/**
|
|
163
209
|
* Management scope as configured. Handed to `isChatManageable` raw: unlike
|
|
164
210
|
* `readChats`, an absent value already means "deny", so there is nothing to
|
|
165
211
|
* tell apart here.
|
|
166
212
|
*/
|
|
167
213
|
/** Chat discovery as configured; absent means "deny", like management scope. */
|
|
214
|
+
/**
|
|
215
|
+
* Кому уходит операционная телеметрия ядра в личке.
|
|
216
|
+
*
|
|
217
|
+
* `operatorIds` — если задан. Иначе `allowFrom`, но только когда это
|
|
218
|
+
* конкретный список: со звёздочкой он означает «пишет кто угодно», и слать
|
|
219
|
+
* туда пути secret-store нельзя (A5-11). Пустой результат означает «оператор
|
|
220
|
+
* не назван», и уведомление подавляется везде.
|
|
221
|
+
*
|
|
222
|
+
* Запоминается при старте аккаунта — см. реестр в system-notice.ts.
|
|
223
|
+
*/
|
|
224
|
+
function resolveAccountOperatorIds(cfg, accountId) {
|
|
225
|
+
const account = cfg?.channels?.["clawgram"]?.accounts?.[accountId];
|
|
226
|
+
const explicit = account?.operatorIds;
|
|
227
|
+
const raw = explicit !== undefined && explicit !== null ? explicit : account?.allowFrom;
|
|
228
|
+
if (raw === undefined || raw === null)
|
|
229
|
+
return [];
|
|
230
|
+
const entries = Array.isArray(raw) ? raw : [raw];
|
|
231
|
+
return entries.map((entry) => String(entry).trim()).filter(Boolean);
|
|
232
|
+
}
|
|
168
233
|
function resolveAccountDiscoverChats(cfg, accountId) {
|
|
169
234
|
return cfg?.channels?.["clawgram"]?.accounts?.[accountId]?.discoverChats;
|
|
170
235
|
}
|
|
@@ -180,55 +245,62 @@ function readAccountManageChats(account) {
|
|
|
180
245
|
return entries.map((entry) => String(entry).trim()).filter(Boolean);
|
|
181
246
|
}
|
|
182
247
|
/**
|
|
183
|
-
*
|
|
184
|
-
* action reachable from the agent's `message` tool.
|
|
248
|
+
* Every accepted spelling of an action, mapped to its canonical name.
|
|
185
249
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* on 2026-08-30 — `thread-list` reached `handleAction` and `topics` did not,
|
|
193
|
-
* from the same caller, on the same chat.
|
|
250
|
+
* One table, not three. The synonyms used to live in
|
|
251
|
+
* `CORE_ACTION_SYNONYMS`, again in `MANAGE_ACTION_ALIASES`, and a third time
|
|
252
|
+
* as `action === "…" || …` chains inside the dispatcher — and the dispatcher
|
|
253
|
+
* read only the chains. A name could therefore be added to a table and to the
|
|
254
|
+
* advertised list and still reach nothing, with the suite none the wiser:
|
|
255
|
+
* it only ever dispatched the native spellings (finding A6-10).
|
|
194
256
|
*
|
|
195
|
-
*
|
|
196
|
-
* `
|
|
197
|
-
*
|
|
198
|
-
* call fell through to the current chat and answered about the wrong one.
|
|
199
|
-
* `readChatTargetParam` is the single list of accepted spellings now.
|
|
257
|
+
* `canonicalAction` is now the only place a name is resolved, and
|
|
258
|
+
* `CORE_ACTION_SYNONYMS` below is derived from this table rather than kept
|
|
259
|
+
* beside it.
|
|
200
260
|
*/
|
|
201
|
-
|
|
261
|
+
const ACTION_ALIASES = {
|
|
262
|
+
send: "send",
|
|
263
|
+
read: "read",
|
|
264
|
+
// `list` is accepted so a caller that guessed the other obvious name is not
|
|
265
|
+
// silently refused.
|
|
266
|
+
list: "read",
|
|
267
|
+
react: "react",
|
|
268
|
+
joins: "joins",
|
|
269
|
+
"upload-file": "upload-file",
|
|
270
|
+
sendAttachment: "upload-file",
|
|
271
|
+
"fetch-media": "fetch-media",
|
|
272
|
+
fetchMedia: "fetch-media",
|
|
273
|
+
"download-media": "fetch-media",
|
|
274
|
+
downloadMedia: "fetch-media",
|
|
275
|
+
getMedia: "fetch-media",
|
|
276
|
+
"download-file": "fetch-media",
|
|
277
|
+
participants: "participants",
|
|
278
|
+
members: "participants",
|
|
279
|
+
"member-info": "participants",
|
|
280
|
+
topics: "topics",
|
|
281
|
+
forumTopics: "topics",
|
|
202
282
|
"thread-list": "topics",
|
|
283
|
+
dialogs: "dialogs",
|
|
284
|
+
chats: "dialogs",
|
|
203
285
|
"channel-list": "dialogs",
|
|
286
|
+
chatInfo: "chatInfo",
|
|
287
|
+
getChatInfo: "chatInfo",
|
|
204
288
|
"channel-info": "chatInfo",
|
|
205
|
-
|
|
206
|
-
|
|
289
|
+
chatMetadata: "chatInfo",
|
|
290
|
+
getChatMetadata: "chatInfo",
|
|
207
291
|
// Chat management. `kick` was already accepted; the rest were advertised
|
|
208
292
|
// under names core does not know and were therefore never callable from the
|
|
209
293
|
// tool at all — 2.19.4 gives them core's nearest name. `transferOwnership`
|
|
210
294
|
// and `inviteLink` have no counterpart in that vocabulary and stay
|
|
211
295
|
// gateway-only, as does `joins`.
|
|
212
|
-
"channel-create": "createGroup",
|
|
213
|
-
addParticipant: "addMembers",
|
|
214
|
-
kick: "removeMember",
|
|
215
|
-
"role-add": "promoteAdmin",
|
|
216
|
-
"role-remove": "demoteAdmin",
|
|
217
|
-
};
|
|
218
|
-
/** Canonical management action for every accepted spelling. */
|
|
219
|
-
const MANAGE_ACTION_ALIASES = {
|
|
220
|
-
// Core's spellings first — these are the only ones the agent's tool can
|
|
221
|
-
// reach; see CORE_ACTION_SYNONYMS.
|
|
222
|
-
"channel-create": "createGroup",
|
|
223
|
-
addParticipant: "addMembers",
|
|
224
|
-
"role-add": "promoteAdmin",
|
|
225
|
-
"role-remove": "demoteAdmin",
|
|
226
296
|
createGroup: "createGroup",
|
|
227
297
|
createChat: "createGroup",
|
|
228
298
|
"create-group": "createGroup",
|
|
299
|
+
"channel-create": "createGroup",
|
|
229
300
|
addMembers: "addMembers",
|
|
230
301
|
addMember: "addMembers",
|
|
231
302
|
"add-members": "addMembers",
|
|
303
|
+
addParticipant: "addMembers",
|
|
232
304
|
removeMember: "removeMember",
|
|
233
305
|
removeMembers: "removeMember",
|
|
234
306
|
"remove-member": "removeMember",
|
|
@@ -237,9 +309,11 @@ const MANAGE_ACTION_ALIASES = {
|
|
|
237
309
|
promote: "promoteAdmin",
|
|
238
310
|
"promote-admin": "promoteAdmin",
|
|
239
311
|
setAdmin: "promoteAdmin",
|
|
312
|
+
"role-add": "promoteAdmin",
|
|
240
313
|
demoteAdmin: "demoteAdmin",
|
|
241
314
|
demote: "demoteAdmin",
|
|
242
315
|
"demote-admin": "demoteAdmin",
|
|
316
|
+
"role-remove": "demoteAdmin",
|
|
243
317
|
transferOwnership: "transferOwnership",
|
|
244
318
|
transferOwner: "transferOwnership",
|
|
245
319
|
"transfer-ownership": "transferOwnership",
|
|
@@ -247,6 +321,43 @@ const MANAGE_ACTION_ALIASES = {
|
|
|
247
321
|
exportInviteLink: "inviteLink",
|
|
248
322
|
"invite-link": "inviteLink",
|
|
249
323
|
};
|
|
324
|
+
/** The canonical action for a spelling; an unknown name stays itself. */
|
|
325
|
+
function canonicalAction(action) {
|
|
326
|
+
return ACTION_ALIASES[action] ?? action;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Core's own name for a clawgram action, and the only thing that makes the
|
|
330
|
+
* action reachable from the agent's `message` tool.
|
|
331
|
+
*
|
|
332
|
+
* Core keys its target policy by `CHANNEL_MESSAGE_ACTION_NAMES`, and an action
|
|
333
|
+
* outside that vocabulary is simultaneously "requires a target" and "does not
|
|
334
|
+
* accept a target" — there is no call that satisfies both. Declaring `chatId`
|
|
335
|
+
* through `messageActionTargetAliases` looks like the fix and is not: core
|
|
336
|
+
* resolves the channel with `getBootstrapChannelPlugin`, which only knows
|
|
337
|
+
* bundled channels, so a plugin channel's declaration is never read. Measured
|
|
338
|
+
* on 2026-08-30 — `thread-list` reached `handleAction` and `topics` did not,
|
|
339
|
+
* from the same caller, on the same chat.
|
|
340
|
+
*
|
|
341
|
+
* Every name on the right maps to core target mode `"none"` except
|
|
342
|
+
* `channel-info`, which is `"channelId"`: the chat arrives in
|
|
343
|
+
* `params.channelId`, a spelling no parser here read until 2.21.0 — so the
|
|
344
|
+
* call fell through to the current chat and answered about the wrong one.
|
|
345
|
+
* `readChatTargetParam` is the single list of accepted spellings now.
|
|
346
|
+
*
|
|
347
|
+
* These spellings are derived from `ACTION_ALIASES` rather than kept beside
|
|
348
|
+
* it; that core actually knows each of them is asserted against the installed
|
|
349
|
+
* core in `core-action-synonyms.test.ts`.
|
|
350
|
+
*/
|
|
351
|
+
const CORE_VOCABULARY_SPELLINGS = [
|
|
352
|
+
"thread-list", "channel-list", "channel-info", "member-info", "download-file",
|
|
353
|
+
"channel-create", "addParticipant", "kick", "role-add", "role-remove",
|
|
354
|
+
];
|
|
355
|
+
exports.CORE_ACTION_SYNONYMS = Object.fromEntries(CORE_VOCABULARY_SPELLINGS.map((name) => [name, ACTION_ALIASES[name]]));
|
|
356
|
+
/** Canonical actions that go through the chat-management gate. */
|
|
357
|
+
const MANAGE_ACTIONS = new Set([
|
|
358
|
+
"createGroup", "addMembers", "removeMember",
|
|
359
|
+
"promoteAdmin", "demoteAdmin", "transferOwnership", "inviteLink",
|
|
360
|
+
]);
|
|
250
361
|
function parseOptionalThreadId(value) {
|
|
251
362
|
if (typeof value === "number") {
|
|
252
363
|
return Number.isFinite(value) ? Math.trunc(value) : undefined;
|
|
@@ -285,9 +396,7 @@ function parseOptionalThreadId(value) {
|
|
|
285
396
|
* the caller degrade instead of throwing.
|
|
286
397
|
*/
|
|
287
398
|
function resolveAgentDirForMedia(cfg) {
|
|
288
|
-
const stateDir =
|
|
289
|
-
? process.env.OPENCLAW_STATE_DIR.trim()
|
|
290
|
-
: node_path_1.default.join(node_os_1.default.homedir(), ".openclaw");
|
|
399
|
+
const stateDir = (0, state_dir_1.resolveStateDir)();
|
|
291
400
|
const configuredId = cfg?.agents?.defaults?.id;
|
|
292
401
|
const agentId = typeof configuredId === "string" && configuredId.trim() ? configuredId.trim() : "main";
|
|
293
402
|
const dir = node_path_1.default.join(stateDir, "agents", agentId, "agent");
|
|
@@ -451,6 +560,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
451
560
|
"Use the `react` action to acknowledge a message with an emoji instead of sending a reply; pass an empty `emoji` (or `remove: true`) to take the reaction back.",
|
|
452
561
|
"Use the `channel-info` action to learn what a chat is — title, type, member count, description, pinned message — instead of guessing from its id. Name the chat with `chatId` and do not pass `target`: core refuses it for this action, and the descriptive spelling `chatInfo` is not callable from this tool at all.",
|
|
453
562
|
"Use the `thread-list` action to list a forum's topics by name (optional `query` narrows by title); that is where a `threadId` comes from when someone names a topic instead of quoting a message in it. Name the chat with `chatId` and do not pass `target` — core refuses it for this action. `topics` is the same call under a name core does not know, and is only reachable through the gateway RPC.",
|
|
563
|
+
"Name the chat for `read` with `target`, never `chatId`: `read` is in core's own vocabulary, so core resolves the destination itself and reads only `to`/`target` — `chatId` is silently ignored and the call is refused as targetless. The chat-shaped reads next to it (`thread-list`, `channel-info`, `member-info`) are the opposite, because core does not know them; that asymmetry is core's, not a typo, and it cost 745 refused reads in the week before 2026-09-04.",
|
|
454
564
|
"Pass that `threadId` to `read` as well: without it a forum read returns every topic interleaved rather than the one that was asked about.",
|
|
455
565
|
"Use the `download-file` action to fetch the attachment on a message `read` reported. Name the chat with `chatId` and the message with `messageId`; do not pass `target` — core refuses it for this action: `mode: \"read\"` returns a description of an image or a transcript of a voice note, `\"file\"` returns a path to reuse, `\"both\"` (default) returns both. `read` only says an attachment exists; this is what brings it.",
|
|
456
566
|
"Use the `channel-list` action to find out which group chats this account is actually in — including ones nobody has configured yet. It reports id, title and type only, never direct chats, and only when the account enables `discoverChats`.",
|
|
@@ -550,6 +660,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
550
660
|
const gram = new gramjs_client_1.GramJsClientManager(resolvedAccount);
|
|
551
661
|
await gram.start();
|
|
552
662
|
runtimes.set(accountId, gram);
|
|
663
|
+
(0, system_notice_1.rememberOperatorIds)(accountId, resolveAccountOperatorIds(cfg, accountId));
|
|
664
|
+
// Область отправки — туда же и по той же причине: в `outbound.*`
|
|
665
|
+
// конфига нет, а барьер нужен и на пути доставки ядра (A5-12).
|
|
666
|
+
(0, send_scope_1.rememberSendScope)(accountId, resolveAccountSendChats(cfg, accountId));
|
|
667
|
+
warnAboutHandleAllowlistEntries(cfg, accountId);
|
|
553
668
|
const pairing = (0, channel_pairing_1.createChannelPairingController)({
|
|
554
669
|
// The controller only reads core.channel.pairing, but its parameter is typed
|
|
555
670
|
// as the full PluginRuntime, and ctx (hence channelRuntime) is untyped.
|
|
@@ -1032,6 +1147,25 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1032
1147
|
});
|
|
1033
1148
|
return;
|
|
1034
1149
|
}
|
|
1150
|
+
// Ядро подклеивает свою телеметрию к полезной нагрузке
|
|
1151
|
+
// хода, и сюда она приходит тем же путём, что ответ.
|
|
1152
|
+
// Проверка стояла только в `outbound.sendText`, то есть
|
|
1153
|
+
// класс инцидента 30.08–01.09 был закрыт для рассылок и
|
|
1154
|
+
// открыт для обычного ответа на упоминание (A5-10).
|
|
1155
|
+
const groupNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
|
|
1156
|
+
targetKind: "group",
|
|
1157
|
+
text: visibleText,
|
|
1158
|
+
});
|
|
1159
|
+
if (groupNotice) {
|
|
1160
|
+
log?.warn?.("clawgram suppressing system notice in group reply", {
|
|
1161
|
+
accountId,
|
|
1162
|
+
chatId: normalized.chatId,
|
|
1163
|
+
messageId: normalized.messageId,
|
|
1164
|
+
noticeKind: groupNotice,
|
|
1165
|
+
textLength: visibleText.length,
|
|
1166
|
+
});
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1035
1169
|
const replyToMessageId = payload.replyToId ? Number(payload.replyToId) : Number(normalized.messageId);
|
|
1036
1170
|
const rememberedAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
|
|
1037
1171
|
accountId: route.accountId ?? accountId,
|
|
@@ -1085,9 +1219,24 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1085
1219
|
// `[[tts:text]]Привет, Вася!…[[/tts:text]]` verbatim. The
|
|
1086
1220
|
// spoken words are kept — a synthesis that did not happen
|
|
1087
1221
|
// should degrade to readable text, not to markup.
|
|
1088
|
-
const
|
|
1222
|
+
const rawFallback = fallbackText
|
|
1089
1223
|
? (0, helpers_1.stripTtsDirectives)((0, helpers_1.stripSilentReplyToken)(fallbackText))
|
|
1090
1224
|
: "";
|
|
1225
|
+
// Тот же фильтр и здесь: последняя реплика в стенограмме
|
|
1226
|
+
// вполне может оказаться именно уведомлением об ошибке.
|
|
1227
|
+
const fallbackNotice = rawFallback
|
|
1228
|
+
? (0, system_notice_1.shouldSuppressGroupSystemNotice)({ targetKind: "group", text: rawFallback })
|
|
1229
|
+
: undefined;
|
|
1230
|
+
if (fallbackNotice) {
|
|
1231
|
+
log?.warn?.("clawgram suppressing system notice in transcript fallback", {
|
|
1232
|
+
accountId,
|
|
1233
|
+
chatId: normalized.chatId,
|
|
1234
|
+
messageId: normalized.messageId,
|
|
1235
|
+
noticeKind: fallbackNotice,
|
|
1236
|
+
textLength: rawFallback.length,
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
const visibleFallbackText = fallbackNotice ? "" : rawFallback;
|
|
1091
1240
|
if (!visibleFallbackText) {
|
|
1092
1241
|
if (fallbackText) {
|
|
1093
1242
|
log?.info?.("clawgram skipping silent transcript fallback", {
|
|
@@ -1569,10 +1718,14 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1569
1718
|
// Both count, because a rehearsal flag that is silently ignored puts
|
|
1570
1719
|
// a real message in a real chat — twice, so far (2.13.1).
|
|
1571
1720
|
const dryRun = (0, helpers_1.resolveDryRun)(dryRunFlag, params);
|
|
1721
|
+
// Every branch below compares the canonical name, so a spelling is
|
|
1722
|
+
// resolved once, here, and `ACTION_ALIASES` is the only place that
|
|
1723
|
+
// decides what a name means. An unknown name stays itself and falls
|
|
1724
|
+
// through to the unsupported-action error, as before.
|
|
1725
|
+
const canonical = canonicalAction(action);
|
|
1572
1726
|
// `read` is what OpenClaw core dispatches (`openclaw message read`,
|
|
1573
|
-
// MCP `messages_read`)
|
|
1574
|
-
|
|
1575
|
-
if (action === "read" || action === "list") {
|
|
1727
|
+
// MCP `messages_read`); `list` resolves to it too.
|
|
1728
|
+
if (canonical === "read") {
|
|
1576
1729
|
const listParams = (0, history_1.parseListMessagesParams)(params);
|
|
1577
1730
|
const listAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1578
1731
|
if (!listAccountId) {
|
|
@@ -1621,9 +1774,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1621
1774
|
// channel and unreachable to the agent. Same `readChats` scope as
|
|
1622
1775
|
// history: this must not become a way to pull bytes out of a chat the
|
|
1623
1776
|
// account was never allowed to read.
|
|
1624
|
-
if (
|
|
1625
|
-
action === "download-media" || action === "downloadMedia" ||
|
|
1626
|
-
action === "getMedia" || action === "download-file") {
|
|
1777
|
+
if (canonical === "fetch-media") {
|
|
1627
1778
|
const fetchParams = (0, fetch_media_1.parseFetchMediaParams)(params);
|
|
1628
1779
|
const fetchAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1629
1780
|
if (!fetchAccountId) {
|
|
@@ -1664,11 +1815,21 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1664
1815
|
// the shared directory is keyed by chat and message, and deleting
|
|
1665
1816
|
// that path would pull the file out from under an earlier `both`
|
|
1666
1817
|
// fetch of the same message that handed the caller a path.
|
|
1667
|
-
|
|
1818
|
+
// Не общий /tmp: там файлы видит каждый локальный пользователь, а на
|
|
1819
|
+
// этом хосте живёт ещё и раннер деплоя. Каталог состояния OpenClaw
|
|
1820
|
+
// принадлежит агенту; если он не задан, остаётся /tmp — но права
|
|
1821
|
+
// 0700/0600 ставятся в любом случае (A5-13).
|
|
1822
|
+
// Каталог состояния принадлежит агенту; при явно заданном
|
|
1823
|
+
// OPENCLAW_STATE_DIR вложения не покидают его.
|
|
1824
|
+
const mediaRoot = process.env.OPENCLAW_STATE_DIR?.trim()
|
|
1825
|
+
? node_path_1.default.join((0, state_dir_1.resolveStateDir)(), "tmp")
|
|
1826
|
+
: node_os_1.default.tmpdir();
|
|
1827
|
+
const sharedFetchDir = node_path_1.default.join(mediaRoot, "clawgram-fetched");
|
|
1668
1828
|
let fetchDir = sharedFetchDir;
|
|
1669
1829
|
if (fetchParams.mode === "read") {
|
|
1670
|
-
const { mkdtemp } = await import("node:fs/promises");
|
|
1671
|
-
|
|
1830
|
+
const { mkdtemp, mkdir } = await import("node:fs/promises");
|
|
1831
|
+
await mkdir(mediaRoot, { recursive: true, mode: 0o700 });
|
|
1832
|
+
fetchDir = await mkdtemp(node_path_1.default.join(mediaRoot, "clawgram-media-"));
|
|
1672
1833
|
}
|
|
1673
1834
|
else {
|
|
1674
1835
|
await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
|
|
@@ -1775,7 +1936,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1775
1936
|
// Membership is a read, so the same `readChats` scope that gates history
|
|
1776
1937
|
// gates it too: this cannot become a way to enumerate chats the account
|
|
1777
1938
|
// was never allowed to read.
|
|
1778
|
-
if (
|
|
1939
|
+
if (canonical === "participants") {
|
|
1779
1940
|
const participantsParams = (0, history_1.parseListParticipantsParams)(params);
|
|
1780
1941
|
const participantsAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1781
1942
|
if (!participantsAccountId) {
|
|
@@ -1815,7 +1976,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1815
1976
|
// id could only be lifted off an inbound message — so a topic nobody had
|
|
1816
1977
|
// written in yet was unreachable, and one named in words was unfindable.
|
|
1817
1978
|
// Titles say what a chat is working on, so the read scope gates them.
|
|
1818
|
-
if (
|
|
1979
|
+
if (canonical === "topics") {
|
|
1819
1980
|
const topicsParams = (0, topics_1.parseTopicsParams)(params);
|
|
1820
1981
|
const topicsAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1821
1982
|
if (!topicsAccountId) {
|
|
@@ -1852,7 +2013,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1852
2013
|
// Which chats this account is in. Not gated by `readChats` — the whole
|
|
1853
2014
|
// point is to find chats that are not in it yet — so it has a gate of
|
|
1854
2015
|
// its own, is metadata only, and never reports direct chats.
|
|
1855
|
-
if (
|
|
2016
|
+
if (canonical === "dialogs") {
|
|
1856
2017
|
const dialogsParams = (0, dialogs_1.parseDialogsParams)(params);
|
|
1857
2018
|
const dialogsAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1858
2019
|
if (!dialogsAccountId) {
|
|
@@ -1888,7 +2049,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1888
2049
|
// Where this account was recently added, and by whom. Reading the journal
|
|
1889
2050
|
// has no scope check of its own: it only ever contains chats this account
|
|
1890
2051
|
// was put into, which is exactly what the caller is allowed to learn.
|
|
1891
|
-
if (
|
|
2052
|
+
if (canonical === "joins") {
|
|
1892
2053
|
const joinsParams = (0, joins_1.parseJoinsParams)(params);
|
|
1893
2054
|
const joinsAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1894
2055
|
if (!joinsAccountId) {
|
|
@@ -1912,8 +2073,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1912
2073
|
// Describing a chat is a read, so the same `readChats` scope that gates
|
|
1913
2074
|
// history gates it too — this must not become a way to learn the title
|
|
1914
2075
|
// and size of a chat the account was never allowed to read.
|
|
1915
|
-
if (
|
|
1916
|
-
|| action === "chatMetadata" || action === "getChatMetadata") {
|
|
2076
|
+
if (canonical === "chatInfo") {
|
|
1917
2077
|
const chatInfoParams = (0, chat_info_1.parseChatInfoParams)(params, toolContext);
|
|
1918
2078
|
const chatInfoAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1919
2079
|
if (!chatInfoAccountId) {
|
|
@@ -1949,12 +2109,17 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1949
2109
|
// A reaction is an outbound act on someone else's message, so it is
|
|
1950
2110
|
// gated like sending rather than like reading — and it respects
|
|
1951
2111
|
// `dryRun`, which reading does not need to.
|
|
1952
|
-
if (
|
|
2112
|
+
if (canonical === "react") {
|
|
1953
2113
|
const reactionParams = (0, reactions_1.parseReactionParams)(params, toolContext);
|
|
1954
2114
|
const reactionAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1955
2115
|
if (!reactionAccountId) {
|
|
1956
2116
|
throw new Error("clawgram: no configured account found");
|
|
1957
2117
|
}
|
|
2118
|
+
// Реакция — видимое действие от имени владельца в чужом чате, и
|
|
2119
|
+
// адресуется она так же, как сообщение: та же область (A5-12).
|
|
2120
|
+
if (!(0, send_scope_1.isChatSendable)(reactionParams.target, resolveAccountSendChats(cfg, reactionAccountId))) {
|
|
2121
|
+
refuseOutboundOutsideScope("react", reactionAccountId, String(reactionParams.target));
|
|
2122
|
+
}
|
|
1958
2123
|
actionLog.info("clawgram handleAction react", {
|
|
1959
2124
|
accountId: reactionAccountId,
|
|
1960
2125
|
dryRun: dryRun === true,
|
|
@@ -1999,7 +2164,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1999
2164
|
// returns after the gate so a dry run exercises the same refusals a
|
|
2000
2165
|
// real call would hit. People's ids stay out of the logs throughout;
|
|
2001
2166
|
// the JSON result carries them to the caller, the journal does not.
|
|
2002
|
-
const manageAction =
|
|
2167
|
+
const manageAction = MANAGE_ACTIONS.has(canonical) ? canonical : undefined;
|
|
2003
2168
|
if (manageAction) {
|
|
2004
2169
|
const manageAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
2005
2170
|
if (!manageAccountId) {
|
|
@@ -2210,13 +2375,17 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2210
2375
|
// and arrives from older callers. A plain `send` carrying a file lands
|
|
2211
2376
|
// here too — `openclaw message send --media` does exactly that, and
|
|
2212
2377
|
// routing it to the text path dropped the file without a word.
|
|
2213
|
-
if (
|
|
2378
|
+
if (canonical === "upload-file" || (canonical === "send" && attachedFile)) {
|
|
2214
2379
|
const rawUploadTo = (0, helpers_1.resolveActionTarget)(params, toolContext);
|
|
2215
2380
|
const uploadTo = (0, helpers_1.normalizeOutboundTarget)(rawUploadTo);
|
|
2216
2381
|
const uploadAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
2217
2382
|
if (!uploadAccountId) {
|
|
2218
2383
|
throw new Error("clawgram: no configured account found");
|
|
2219
2384
|
}
|
|
2385
|
+
// Та же граница, что у `send`: файл наружу — такое же исходящее.
|
|
2386
|
+
if (!(0, send_scope_1.isChatSendable)(uploadTo, resolveAccountSendChats(cfg, uploadAccountId))) {
|
|
2387
|
+
refuseOutboundOutsideScope("upload-file", uploadAccountId, uploadTo);
|
|
2388
|
+
}
|
|
2220
2389
|
const file = attachedFile;
|
|
2221
2390
|
if (!file) {
|
|
2222
2391
|
throw new Error("clawgram: upload-file requires filePath, path, media, or mediaUrl");
|
|
@@ -2306,6 +2475,12 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2306
2475
|
if (!resolvedAccountId) {
|
|
2307
2476
|
throw new Error("clawgram: no configured account found");
|
|
2308
2477
|
}
|
|
2478
|
+
// Проверка ПОСЛЕ резолва аккаунта и ДО любой доставки: область задаётся
|
|
2479
|
+
// на аккаунт, а отказ должен случиться раньше, чем цель разрешена в
|
|
2480
|
+
// Telegram-сущность — resolve сам по себе виден собеседнику (A5-12).
|
|
2481
|
+
if (!(0, send_scope_1.isChatSendable)(to, resolveAccountSendChats(cfg, resolvedAccountId))) {
|
|
2482
|
+
refuseOutboundOutsideScope("send", resolvedAccountId, to);
|
|
2483
|
+
}
|
|
2309
2484
|
const currentChannelId = toolContext?.currentChannelId?.trim() ?? "";
|
|
2310
2485
|
const currentMessageId = toolContext?.currentMessageId;
|
|
2311
2486
|
const currentChannelTarget = currentChannelId ? (0, helpers_1.normalizeOutboundTarget)(currentChannelId) : "";
|
|
@@ -2459,7 +2634,23 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2459
2634
|
if (!raw) {
|
|
2460
2635
|
return { ok: false, error: new Error("clawgram: no delivery target — pass `to` or use a session with a bound chat") };
|
|
2461
2636
|
}
|
|
2462
|
-
|
|
2637
|
+
const target = (0, helpers_1.normalizeOutboundTarget)(raw);
|
|
2638
|
+
// Тот же барьер, что у `handleAction`: доставка ядра (`--deliver`,
|
|
2639
|
+
// анонсы субагентов) идёт этим путём и мимо той проверки. Отказ
|
|
2640
|
+
// здесь возвращается результатом, а не броском: бросок в этом хуке
|
|
2641
|
+
// роняет весь gateway (грабли 06.08.2026, выше).
|
|
2642
|
+
if (!(0, send_scope_1.isChatSendable)(target, (0, send_scope_1.sendScopeFor)(ctx.accountId))) {
|
|
2643
|
+
const reason = (0, send_scope_1.isPhoneNumberTarget)(target)
|
|
2644
|
+
? "phone-number target"
|
|
2645
|
+
: "chat outside send scope";
|
|
2646
|
+
actionLog.warn("clawgram outbound resolveTarget refused", {
|
|
2647
|
+
accountId: ctx.accountId,
|
|
2648
|
+
target,
|
|
2649
|
+
reason,
|
|
2650
|
+
});
|
|
2651
|
+
return { ok: false, error: new Error(`clawgram: not-allowed-chat ${target}`) };
|
|
2652
|
+
}
|
|
2653
|
+
return { ok: true, to: target };
|
|
2463
2654
|
}
|
|
2464
2655
|
catch (err) {
|
|
2465
2656
|
return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
|
|
@@ -2495,6 +2686,8 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2495
2686
|
const suppressedNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
|
|
2496
2687
|
targetKind: (0, helpers_1.inferOutboundTargetKind)(ctx.to),
|
|
2497
2688
|
text: ctx.text,
|
|
2689
|
+
to: ctx.to,
|
|
2690
|
+
operatorIds: (0, system_notice_1.operatorIdsFor)(ctx.accountId),
|
|
2498
2691
|
});
|
|
2499
2692
|
if (suppressedNotice) {
|
|
2500
2693
|
actionLog.warn("clawgram suppressing system notice in group", {
|
|
@@ -2584,6 +2777,43 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2584
2777
|
if (!file) {
|
|
2585
2778
|
throw new Error("clawgram: sendMedia requires filePath or mediaUrl");
|
|
2586
2779
|
}
|
|
2780
|
+
// Ниже — проверки, которые у `sendText` были, а здесь не было ни
|
|
2781
|
+
// одной: путь доставки медиа писался отдельно и обзавёлся только
|
|
2782
|
+
// своими границами (находка A6-18).
|
|
2783
|
+
const mediaTarget = (0, helpers_1.normalizeOutboundTarget)(ctx.to);
|
|
2784
|
+
// Область отправки: файл наружу — такое же исходящее, как текст.
|
|
2785
|
+
// `resolveTarget` ядро зовёт не на каждом пути, поэтому проверяем и тут.
|
|
2786
|
+
if (!(0, send_scope_1.isChatSendable)(mediaTarget, (0, send_scope_1.sendScopeFor)(ctx.accountId))) {
|
|
2787
|
+
actionLog.warn("clawgram outbound sendMedia refused", {
|
|
2788
|
+
accountId: ctx.accountId,
|
|
2789
|
+
target: mediaTarget,
|
|
2790
|
+
reason: (0, send_scope_1.isPhoneNumberTarget)(mediaTarget) ? "phone-number target" : "chat outside send scope",
|
|
2791
|
+
});
|
|
2792
|
+
return { skipped: "not-allowed" };
|
|
2793
|
+
}
|
|
2794
|
+
// Молчаливый ответ: подпись с токеном молчания означает «ничего не
|
|
2795
|
+
// говорить», и отправлять файл с ним в подписи — тем более.
|
|
2796
|
+
const mediaCaption = ctx.caption ?? ctx.text;
|
|
2797
|
+
if (mediaCaption?.trim() && (0, helpers_1.isSilentReplyText)(mediaCaption)) {
|
|
2798
|
+
actionLog.info("clawgram suppressing silent outbound media", {
|
|
2799
|
+
accountId: ctx.accountId,
|
|
2800
|
+
rawTo: ctx.to,
|
|
2801
|
+
});
|
|
2802
|
+
return { skipped: "silent" };
|
|
2803
|
+
}
|
|
2804
|
+
// Обращение в группе — то же, что у текста: адрес принадлежит
|
|
2805
|
+
// конкретному входящему сообщению, а не последнему говорившему.
|
|
2806
|
+
const mediaReplyAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
|
|
2807
|
+
accountId: ctx.accountId,
|
|
2808
|
+
chatId: ctx.to,
|
|
2809
|
+
replyToId: ctx.replyToId,
|
|
2810
|
+
});
|
|
2811
|
+
// Чего здесь НЕТ намеренно:
|
|
2812
|
+
// — подавление эха хода (`hadTurnSendJustNow`): у текста дубль стоит
|
|
2813
|
+
// лишнего сообщения, а у медиа отказ стоит потерянного файла —
|
|
2814
|
+
// картинку агент готовил, и второй раз она не появится;
|
|
2815
|
+
// — подавление служебных сообщений ядра в группах: они текстовые,
|
|
2816
|
+
// медиа-доставка ими не бывает.
|
|
2587
2817
|
const messageThreadId = parseOptionalThreadId(ctx.threadId);
|
|
2588
2818
|
// Same normalization `sendText` does two functions up. Without it the
|
|
2589
2819
|
// channel prefix reaches peer resolution and the send throws — which is
|
|
@@ -2593,7 +2823,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2593
2823
|
const sent = await gram.sendMedia({
|
|
2594
2824
|
target,
|
|
2595
2825
|
file,
|
|
2596
|
-
|
|
2826
|
+
// Подпись получает то же обращение, что и текстовый ответ.
|
|
2827
|
+
caption: mediaCaption
|
|
2828
|
+
? (0, helpers_1.prefixReplyTextToAddress)(mediaCaption, mediaReplyAddress)
|
|
2829
|
+
: mediaCaption,
|
|
2597
2830
|
// Captions follow the account reply format like every other reply:
|
|
2598
2831
|
// they are the same agent prose, just attached to a file (2.15.0).
|
|
2599
2832
|
parseMode: gram.replyParseMode,
|