clawgram 2.21.1 → 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 +289 -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");
|
|
@@ -551,6 +660,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
551
660
|
const gram = new gramjs_client_1.GramJsClientManager(resolvedAccount);
|
|
552
661
|
await gram.start();
|
|
553
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);
|
|
554
668
|
const pairing = (0, channel_pairing_1.createChannelPairingController)({
|
|
555
669
|
// The controller only reads core.channel.pairing, but its parameter is typed
|
|
556
670
|
// as the full PluginRuntime, and ctx (hence channelRuntime) is untyped.
|
|
@@ -1033,6 +1147,25 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1033
1147
|
});
|
|
1034
1148
|
return;
|
|
1035
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
|
+
}
|
|
1036
1169
|
const replyToMessageId = payload.replyToId ? Number(payload.replyToId) : Number(normalized.messageId);
|
|
1037
1170
|
const rememberedAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
|
|
1038
1171
|
accountId: route.accountId ?? accountId,
|
|
@@ -1086,9 +1219,24 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1086
1219
|
// `[[tts:text]]Привет, Вася!…[[/tts:text]]` verbatim. The
|
|
1087
1220
|
// spoken words are kept — a synthesis that did not happen
|
|
1088
1221
|
// should degrade to readable text, not to markup.
|
|
1089
|
-
const
|
|
1222
|
+
const rawFallback = fallbackText
|
|
1090
1223
|
? (0, helpers_1.stripTtsDirectives)((0, helpers_1.stripSilentReplyToken)(fallbackText))
|
|
1091
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;
|
|
1092
1240
|
if (!visibleFallbackText) {
|
|
1093
1241
|
if (fallbackText) {
|
|
1094
1242
|
log?.info?.("clawgram skipping silent transcript fallback", {
|
|
@@ -1570,10 +1718,14 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1570
1718
|
// Both count, because a rehearsal flag that is silently ignored puts
|
|
1571
1719
|
// a real message in a real chat — twice, so far (2.13.1).
|
|
1572
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);
|
|
1573
1726
|
// `read` is what OpenClaw core dispatches (`openclaw message read`,
|
|
1574
|
-
// MCP `messages_read`)
|
|
1575
|
-
|
|
1576
|
-
if (action === "read" || action === "list") {
|
|
1727
|
+
// MCP `messages_read`); `list` resolves to it too.
|
|
1728
|
+
if (canonical === "read") {
|
|
1577
1729
|
const listParams = (0, history_1.parseListMessagesParams)(params);
|
|
1578
1730
|
const listAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1579
1731
|
if (!listAccountId) {
|
|
@@ -1622,9 +1774,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1622
1774
|
// channel and unreachable to the agent. Same `readChats` scope as
|
|
1623
1775
|
// history: this must not become a way to pull bytes out of a chat the
|
|
1624
1776
|
// account was never allowed to read.
|
|
1625
|
-
if (
|
|
1626
|
-
action === "download-media" || action === "downloadMedia" ||
|
|
1627
|
-
action === "getMedia" || action === "download-file") {
|
|
1777
|
+
if (canonical === "fetch-media") {
|
|
1628
1778
|
const fetchParams = (0, fetch_media_1.parseFetchMediaParams)(params);
|
|
1629
1779
|
const fetchAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1630
1780
|
if (!fetchAccountId) {
|
|
@@ -1665,11 +1815,21 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1665
1815
|
// the shared directory is keyed by chat and message, and deleting
|
|
1666
1816
|
// that path would pull the file out from under an earlier `both`
|
|
1667
1817
|
// fetch of the same message that handed the caller a path.
|
|
1668
|
-
|
|
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");
|
|
1669
1828
|
let fetchDir = sharedFetchDir;
|
|
1670
1829
|
if (fetchParams.mode === "read") {
|
|
1671
|
-
const { mkdtemp } = await import("node:fs/promises");
|
|
1672
|
-
|
|
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-"));
|
|
1673
1833
|
}
|
|
1674
1834
|
else {
|
|
1675
1835
|
await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
|
|
@@ -1776,7 +1936,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1776
1936
|
// Membership is a read, so the same `readChats` scope that gates history
|
|
1777
1937
|
// gates it too: this cannot become a way to enumerate chats the account
|
|
1778
1938
|
// was never allowed to read.
|
|
1779
|
-
if (
|
|
1939
|
+
if (canonical === "participants") {
|
|
1780
1940
|
const participantsParams = (0, history_1.parseListParticipantsParams)(params);
|
|
1781
1941
|
const participantsAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1782
1942
|
if (!participantsAccountId) {
|
|
@@ -1816,7 +1976,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1816
1976
|
// id could only be lifted off an inbound message — so a topic nobody had
|
|
1817
1977
|
// written in yet was unreachable, and one named in words was unfindable.
|
|
1818
1978
|
// Titles say what a chat is working on, so the read scope gates them.
|
|
1819
|
-
if (
|
|
1979
|
+
if (canonical === "topics") {
|
|
1820
1980
|
const topicsParams = (0, topics_1.parseTopicsParams)(params);
|
|
1821
1981
|
const topicsAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1822
1982
|
if (!topicsAccountId) {
|
|
@@ -1853,7 +2013,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1853
2013
|
// Which chats this account is in. Not gated by `readChats` — the whole
|
|
1854
2014
|
// point is to find chats that are not in it yet — so it has a gate of
|
|
1855
2015
|
// its own, is metadata only, and never reports direct chats.
|
|
1856
|
-
if (
|
|
2016
|
+
if (canonical === "dialogs") {
|
|
1857
2017
|
const dialogsParams = (0, dialogs_1.parseDialogsParams)(params);
|
|
1858
2018
|
const dialogsAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1859
2019
|
if (!dialogsAccountId) {
|
|
@@ -1889,7 +2049,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1889
2049
|
// Where this account was recently added, and by whom. Reading the journal
|
|
1890
2050
|
// has no scope check of its own: it only ever contains chats this account
|
|
1891
2051
|
// was put into, which is exactly what the caller is allowed to learn.
|
|
1892
|
-
if (
|
|
2052
|
+
if (canonical === "joins") {
|
|
1893
2053
|
const joinsParams = (0, joins_1.parseJoinsParams)(params);
|
|
1894
2054
|
const joinsAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1895
2055
|
if (!joinsAccountId) {
|
|
@@ -1913,8 +2073,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1913
2073
|
// Describing a chat is a read, so the same `readChats` scope that gates
|
|
1914
2074
|
// history gates it too — this must not become a way to learn the title
|
|
1915
2075
|
// and size of a chat the account was never allowed to read.
|
|
1916
|
-
if (
|
|
1917
|
-
|| action === "chatMetadata" || action === "getChatMetadata") {
|
|
2076
|
+
if (canonical === "chatInfo") {
|
|
1918
2077
|
const chatInfoParams = (0, chat_info_1.parseChatInfoParams)(params, toolContext);
|
|
1919
2078
|
const chatInfoAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1920
2079
|
if (!chatInfoAccountId) {
|
|
@@ -1950,12 +2109,17 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1950
2109
|
// A reaction is an outbound act on someone else's message, so it is
|
|
1951
2110
|
// gated like sending rather than like reading — and it respects
|
|
1952
2111
|
// `dryRun`, which reading does not need to.
|
|
1953
|
-
if (
|
|
2112
|
+
if (canonical === "react") {
|
|
1954
2113
|
const reactionParams = (0, reactions_1.parseReactionParams)(params, toolContext);
|
|
1955
2114
|
const reactionAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
1956
2115
|
if (!reactionAccountId) {
|
|
1957
2116
|
throw new Error("clawgram: no configured account found");
|
|
1958
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
|
+
}
|
|
1959
2123
|
actionLog.info("clawgram handleAction react", {
|
|
1960
2124
|
accountId: reactionAccountId,
|
|
1961
2125
|
dryRun: dryRun === true,
|
|
@@ -2000,7 +2164,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2000
2164
|
// returns after the gate so a dry run exercises the same refusals a
|
|
2001
2165
|
// real call would hit. People's ids stay out of the logs throughout;
|
|
2002
2166
|
// the JSON result carries them to the caller, the journal does not.
|
|
2003
|
-
const manageAction =
|
|
2167
|
+
const manageAction = MANAGE_ACTIONS.has(canonical) ? canonical : undefined;
|
|
2004
2168
|
if (manageAction) {
|
|
2005
2169
|
const manageAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
2006
2170
|
if (!manageAccountId) {
|
|
@@ -2211,13 +2375,17 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2211
2375
|
// and arrives from older callers. A plain `send` carrying a file lands
|
|
2212
2376
|
// here too — `openclaw message send --media` does exactly that, and
|
|
2213
2377
|
// routing it to the text path dropped the file without a word.
|
|
2214
|
-
if (
|
|
2378
|
+
if (canonical === "upload-file" || (canonical === "send" && attachedFile)) {
|
|
2215
2379
|
const rawUploadTo = (0, helpers_1.resolveActionTarget)(params, toolContext);
|
|
2216
2380
|
const uploadTo = (0, helpers_1.normalizeOutboundTarget)(rawUploadTo);
|
|
2217
2381
|
const uploadAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
2218
2382
|
if (!uploadAccountId) {
|
|
2219
2383
|
throw new Error("clawgram: no configured account found");
|
|
2220
2384
|
}
|
|
2385
|
+
// Та же граница, что у `send`: файл наружу — такое же исходящее.
|
|
2386
|
+
if (!(0, send_scope_1.isChatSendable)(uploadTo, resolveAccountSendChats(cfg, uploadAccountId))) {
|
|
2387
|
+
refuseOutboundOutsideScope("upload-file", uploadAccountId, uploadTo);
|
|
2388
|
+
}
|
|
2221
2389
|
const file = attachedFile;
|
|
2222
2390
|
if (!file) {
|
|
2223
2391
|
throw new Error("clawgram: upload-file requires filePath, path, media, or mediaUrl");
|
|
@@ -2307,6 +2475,12 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2307
2475
|
if (!resolvedAccountId) {
|
|
2308
2476
|
throw new Error("clawgram: no configured account found");
|
|
2309
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
|
+
}
|
|
2310
2484
|
const currentChannelId = toolContext?.currentChannelId?.trim() ?? "";
|
|
2311
2485
|
const currentMessageId = toolContext?.currentMessageId;
|
|
2312
2486
|
const currentChannelTarget = currentChannelId ? (0, helpers_1.normalizeOutboundTarget)(currentChannelId) : "";
|
|
@@ -2460,7 +2634,23 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2460
2634
|
if (!raw) {
|
|
2461
2635
|
return { ok: false, error: new Error("clawgram: no delivery target — pass `to` or use a session with a bound chat") };
|
|
2462
2636
|
}
|
|
2463
|
-
|
|
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 };
|
|
2464
2654
|
}
|
|
2465
2655
|
catch (err) {
|
|
2466
2656
|
return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
|
|
@@ -2496,6 +2686,8 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2496
2686
|
const suppressedNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
|
|
2497
2687
|
targetKind: (0, helpers_1.inferOutboundTargetKind)(ctx.to),
|
|
2498
2688
|
text: ctx.text,
|
|
2689
|
+
to: ctx.to,
|
|
2690
|
+
operatorIds: (0, system_notice_1.operatorIdsFor)(ctx.accountId),
|
|
2499
2691
|
});
|
|
2500
2692
|
if (suppressedNotice) {
|
|
2501
2693
|
actionLog.warn("clawgram suppressing system notice in group", {
|
|
@@ -2585,6 +2777,43 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2585
2777
|
if (!file) {
|
|
2586
2778
|
throw new Error("clawgram: sendMedia requires filePath or mediaUrl");
|
|
2587
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
|
+
// медиа-доставка ими не бывает.
|
|
2588
2817
|
const messageThreadId = parseOptionalThreadId(ctx.threadId);
|
|
2589
2818
|
// Same normalization `sendText` does two functions up. Without it the
|
|
2590
2819
|
// channel prefix reaches peer resolution and the send throws — which is
|
|
@@ -2594,7 +2823,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2594
2823
|
const sent = await gram.sendMedia({
|
|
2595
2824
|
target,
|
|
2596
2825
|
file,
|
|
2597
|
-
|
|
2826
|
+
// Подпись получает то же обращение, что и текстовый ответ.
|
|
2827
|
+
caption: mediaCaption
|
|
2828
|
+
? (0, helpers_1.prefixReplyTextToAddress)(mediaCaption, mediaReplyAddress)
|
|
2829
|
+
: mediaCaption,
|
|
2598
2830
|
// Captions follow the account reply format like every other reply:
|
|
2599
2831
|
// they are the same agent prose, just attached to a file (2.15.0).
|
|
2600
2832
|
parseMode: gram.replyParseMode,
|