clawgram 2.21.1 → 2.23.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/dist/channel.js CHANGED
@@ -3,15 +3,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.createChannelPlugin = exports.CORE_ACTION_SYNONYMS = void 0;
6
+ exports.createChannelPlugin = exports.canonicalAction = exports.CORE_ACTION_SYNONYMS = void 0;
7
7
  const core_1 = require("openclaw/plugin-sdk/core");
8
8
  const node_os_1 = __importDefault(require("node:os"));
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
- const node_fs_1 = require("node:fs");
11
- /** Attachments above this are left unread: a long recording or a huge image is
12
- * a different conversation from a spoken line or a screenshot, and the
13
- * transfer is not free. */
14
- const INBOUND_MEDIA_MAX_BYTES = 25 * 1024 * 1024;
15
10
  /**
16
11
  * How long a file fetched by `fetch-media` stays on disk.
17
12
  *
@@ -20,7 +15,10 @@ const INBOUND_MEDIA_MAX_BYTES = 25 * 1024 * 1024;
20
15
  * that a chat full of images does not silently become a copy of itself in the
21
16
  * temp directory.
22
17
  */
23
- const FETCHED_MEDIA_TTL_MS = 24 * 60 * 60 * 1000;
18
+ // Час, а не сутки: `fetch-media` существует ради «прочитать и переслать», и
19
+ // файл нужен ровно на время хода. Сутки означали сутки чужой личной переписки
20
+ // на диске (A5-13).
21
+ const FETCHED_MEDIA_TTL_MS = 60 * 60 * 1000;
24
22
  /**
25
23
  * What this channel promises the Gateway.
26
24
  *
@@ -67,11 +65,13 @@ const constants_1 = require("./constants");
67
65
  const gramjs_client_1 = require("./gramjs-client");
68
66
  const normalize_1 = require("./normalize");
69
67
  const history_1 = require("./history");
68
+ const send_scope_1 = require("./send-scope");
70
69
  const joins_1 = require("./joins");
71
70
  const reactions_1 = require("./reactions");
72
71
  const manage_1 = require("./manage");
73
72
  const silent_reaction_1 = require("./silent-reaction");
74
73
  const system_notice_1 = require("./system-notice");
74
+ const state_dir_1 = require("./state-dir");
75
75
  const chat_info_1 = require("./chat-info");
76
76
  const topics_1 = require("./topics");
77
77
  const dialogs_1 = require("./dialogs");
@@ -159,12 +159,71 @@ function readAccountReadChats(account) {
159
159
  function resolveAccountReadChats(cfg, accountId) {
160
160
  return readAccountReadChats(cfg?.channels?.["clawgram"]?.accounts?.[accountId]);
161
161
  }
162
+ /**
163
+ * Outbound scope as configured. Handed to `isChatSendable` raw: an absent
164
+ * value means "unrestricted" and an empty list means "deny", and only the
165
+ * raw value tells those apart — same shape as `readChats`.
166
+ */
167
+ /**
168
+ * Хэндл в `allowFrom` — обещание, которое Telegram не держит.
169
+ *
170
+ * Запись `@username` утверждает не про человека, а про хэндл: хэндл можно
171
+ * освободить, и тогда его берёт кто угодно — запись начинает пускать
172
+ * постороннего, ничего об этом не сказав. Числовой id так не переходит из рук
173
+ * в руки. Отказываться от хэндлов нельзя (люди пишут ими, и конфиг у многих
174
+ * уже такой), но молчать об этом тоже не годится — поэтому предупреждение
175
+ * один раз при старте аккаунта (находка A5-16).
176
+ */
177
+ function warnAboutHandleAllowlistEntries(cfg, accountId) {
178
+ const account = cfg?.channels?.["clawgram"]?.accounts?.[accountId];
179
+ const entries = Array.isArray(account?.allowFrom) ? account.allowFrom : [];
180
+ const handles = entries
181
+ .map((entry) => String(entry ?? "").trim())
182
+ .filter((entry) => entry.startsWith("@"));
183
+ if (handles.length === 0) {
184
+ return;
185
+ }
186
+ actionLog.warn("clawgram allowFrom names handles, not ids", {
187
+ accountId,
188
+ // Сами хэндлы — это про людей: в лог уходит только их число.
189
+ handleEntries: handles.length,
190
+ why: "a released handle can be taken by someone else; numeric ids do not change hands",
191
+ });
192
+ }
193
+ function resolveAccountSendChats(cfg, accountId) {
194
+ return cfg?.channels?.["clawgram"]?.accounts?.[accountId]?.sendChats;
195
+ }
196
+ /** One refusal for every outbound action, so the three read the same. */
197
+ function refuseOutboundOutsideScope(action, accountId, target) {
198
+ const reason = (0, send_scope_1.isPhoneNumberTarget)(target) ? "phone-number target" : "chat outside send scope";
199
+ actionLog.warn(`clawgram ${action} refused: ${reason}`, { accountId, target });
200
+ throw new Error(`clawgram: not-allowed-chat ${target}`);
201
+ }
162
202
  /**
163
203
  * Management scope as configured. Handed to `isChatManageable` raw: unlike
164
204
  * `readChats`, an absent value already means "deny", so there is nothing to
165
205
  * tell apart here.
166
206
  */
167
207
  /** Chat discovery as configured; absent means "deny", like management scope. */
208
+ /**
209
+ * Кому уходит операционная телеметрия ядра в личке.
210
+ *
211
+ * `operatorIds` — если задан. Иначе `allowFrom`, но только когда это
212
+ * конкретный список: со звёздочкой он означает «пишет кто угодно», и слать
213
+ * туда пути secret-store нельзя (A5-11). Пустой результат означает «оператор
214
+ * не назван», и уведомление подавляется везде.
215
+ *
216
+ * Запоминается при старте аккаунта — см. реестр в system-notice.ts.
217
+ */
218
+ function resolveAccountOperatorIds(cfg, accountId) {
219
+ const account = cfg?.channels?.["clawgram"]?.accounts?.[accountId];
220
+ const explicit = account?.operatorIds;
221
+ const raw = explicit !== undefined && explicit !== null ? explicit : account?.allowFrom;
222
+ if (raw === undefined || raw === null)
223
+ return [];
224
+ const entries = Array.isArray(raw) ? raw : [raw];
225
+ return entries.map((entry) => String(entry).trim()).filter(Boolean);
226
+ }
168
227
  function resolveAccountDiscoverChats(cfg, accountId) {
169
228
  return cfg?.channels?.["clawgram"]?.accounts?.[accountId]?.discoverChats;
170
229
  }
@@ -179,88 +238,10 @@ function readAccountManageChats(account) {
179
238
  const entries = Array.isArray(raw) ? raw : [raw];
180
239
  return entries.map((entry) => String(entry).trim()).filter(Boolean);
181
240
  }
182
- /**
183
- * Core's own name for a clawgram action, and the only thing that makes the
184
- * action reachable from the agent's `message` tool.
185
- *
186
- * Core keys its target policy by `CHANNEL_MESSAGE_ACTION_NAMES`, and an action
187
- * outside that vocabulary is simultaneously "requires a target" and "does not
188
- * accept a target" — there is no call that satisfies both. Declaring `chatId`
189
- * through `messageActionTargetAliases` looks like the fix and is not: core
190
- * resolves the channel with `getBootstrapChannelPlugin`, which only knows
191
- * bundled channels, so a plugin channel's declaration is never read. Measured
192
- * on 2026-08-30 — `thread-list` reached `handleAction` and `topics` did not,
193
- * from the same caller, on the same chat.
194
- *
195
- * Every name on the right maps to core target mode `"none"` except
196
- * `channel-info`, which is `"channelId"`: the chat arrives in
197
- * `params.channelId`, a spelling no parser here read until 2.21.0 — so the
198
- * call fell through to the current chat and answered about the wrong one.
199
- * `readChatTargetParam` is the single list of accepted spellings now.
200
- */
201
- exports.CORE_ACTION_SYNONYMS = {
202
- "thread-list": "topics",
203
- "channel-list": "dialogs",
204
- "channel-info": "chatInfo",
205
- "member-info": "participants",
206
- "download-file": "fetch-media",
207
- // Chat management. `kick` was already accepted; the rest were advertised
208
- // under names core does not know and were therefore never callable from the
209
- // tool at all — 2.19.4 gives them core's nearest name. `transferOwnership`
210
- // and `inviteLink` have no counterpart in that vocabulary and stay
211
- // 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
- createGroup: "createGroup",
227
- createChat: "createGroup",
228
- "create-group": "createGroup",
229
- addMembers: "addMembers",
230
- addMember: "addMembers",
231
- "add-members": "addMembers",
232
- removeMember: "removeMember",
233
- removeMembers: "removeMember",
234
- "remove-member": "removeMember",
235
- kick: "removeMember",
236
- promoteAdmin: "promoteAdmin",
237
- promote: "promoteAdmin",
238
- "promote-admin": "promoteAdmin",
239
- setAdmin: "promoteAdmin",
240
- demoteAdmin: "demoteAdmin",
241
- demote: "demoteAdmin",
242
- "demote-admin": "demoteAdmin",
243
- transferOwnership: "transferOwnership",
244
- transferOwner: "transferOwnership",
245
- "transfer-ownership": "transferOwnership",
246
- inviteLink: "inviteLink",
247
- exportInviteLink: "inviteLink",
248
- "invite-link": "inviteLink",
249
- };
250
- function parseOptionalThreadId(value) {
251
- if (typeof value === "number") {
252
- return Number.isFinite(value) ? Math.trunc(value) : undefined;
253
- }
254
- if (typeof value !== "string") {
255
- return undefined;
256
- }
257
- const trimmed = value.trim();
258
- if (!trimmed || !/^\d+$/.test(trimmed)) {
259
- return undefined;
260
- }
261
- const parsed = Number.parseInt(trimmed, 10);
262
- return Number.isFinite(parsed) ? parsed : undefined;
263
- }
241
+ const actions_1 = require("./actions");
242
+ Object.defineProperty(exports, "CORE_ACTION_SYNONYMS", { enumerable: true, get: function () { return actions_1.CORE_ACTION_SYNONYMS; } });
243
+ Object.defineProperty(exports, "canonicalAction", { enumerable: true, get: function () { return actions_1.canonicalAction; } });
244
+ const outbound_1 = require("./outbound");
264
245
  /**
265
246
  * Turns an inbound attachment into text the agent can read.
266
247
  *
@@ -274,128 +255,7 @@ function parseOptionalThreadId(value) {
274
255
  * "you sent something I could not read" than staying silent, which is
275
256
  * indistinguishable from being offline.
276
257
  */
277
- /**
278
- * Locates the agent directory that image understanding needs.
279
- *
280
- * Image models are called with the agent's own credentials, so the pipeline
281
- * refuses to run without this path — audio does not need it, which is why
282
- * voice notes worked before images did. The platform exposes no resolver to
283
- * plugins, so the documented layout is reconstructed here and checked before
284
- * use: a wrong guess would fail the read anyway, and returning undefined lets
285
- * the caller degrade instead of throwing.
286
- */
287
- function resolveAgentDirForMedia(cfg) {
288
- const stateDir = typeof process.env.OPENCLAW_STATE_DIR === "string" && process.env.OPENCLAW_STATE_DIR.trim()
289
- ? process.env.OPENCLAW_STATE_DIR.trim()
290
- : node_path_1.default.join(node_os_1.default.homedir(), ".openclaw");
291
- const configuredId = cfg?.agents?.defaults?.id;
292
- const agentId = typeof configuredId === "string" && configuredId.trim() ? configuredId.trim() : "main";
293
- const dir = node_path_1.default.join(stateDir, "agents", agentId, "agent");
294
- return (0, node_fs_1.existsSync)(dir) ? dir : undefined;
295
- }
296
- /**
297
- * Turns a downloaded attachment into text.
298
- *
299
- * Shared by the inbound path and by `fetch-media`: the backend choice lives in
300
- * `runtime.mediaUnderstanding`, and both callers have to make exactly the same
301
- * call — an image read on arrival and the same image read on request must not
302
- * become two different readings because two call sites drifted.
303
- */
304
- async function understandAttachmentFile(params) {
305
- const media = params.runtime?.mediaUnderstanding;
306
- if (!media)
307
- return undefined;
308
- const result = params.understanding === "transcript"
309
- ? await media.transcribeAudioFile({
310
- filePath: params.filePath,
311
- cfg: params.cfg,
312
- mime: params.mimeType,
313
- })
314
- : await media.describeImageFile({
315
- filePath: params.filePath,
316
- cfg: params.cfg,
317
- mime: params.mimeType,
318
- agentDir: resolveAgentDirForMedia(params.cfg),
319
- });
320
- const text = typeof result?.text === "string" ? result.text.trim() : "";
321
- return text || undefined;
322
- }
323
- async function readInboundAttachment(params) {
324
- const media = params.runtime?.mediaUnderstanding;
325
- const message = params.event?.message;
326
- if (!media || !message) {
327
- return undefined;
328
- }
329
- let downloaded;
330
- try {
331
- downloaded = await (0, media_1.downloadInboundMediaToTempFile)({
332
- client: params.gram.getClient(),
333
- message,
334
- maxBytes: INBOUND_MEDIA_MAX_BYTES,
335
- tmpDir: node_os_1.default.tmpdir(),
336
- });
337
- }
338
- catch (err) {
339
- params.log?.info?.("clawgram attachment download failed", {
340
- accountId: params.accountId,
341
- chatId: params.chatId,
342
- messageId: params.messageId,
343
- error: String(err),
344
- });
345
- return undefined;
346
- }
347
- if (!downloaded) {
348
- return undefined;
349
- }
350
- try {
351
- const read = await understandAttachmentFile({
352
- runtime: params.runtime,
353
- cfg: params.cfg,
354
- filePath: downloaded.path,
355
- mimeType: downloaded.mimeType,
356
- understanding: downloaded.understanding,
357
- });
358
- if (!read) {
359
- params.log?.info?.("clawgram attachment read empty", {
360
- accountId: params.accountId,
361
- chatId: params.chatId,
362
- messageId: params.messageId,
363
- understanding: downloaded.understanding,
364
- });
365
- return undefined;
366
- }
367
- params.log?.info?.("clawgram attachment read", {
368
- accountId: params.accountId,
369
- chatId: params.chatId,
370
- messageId: params.messageId,
371
- understanding: downloaded.understanding,
372
- characters: read.length,
373
- });
374
- return { text: read, understanding: downloaded.understanding };
375
- }
376
- catch (err) {
377
- params.log?.info?.("clawgram attachment read failed", {
378
- accountId: params.accountId,
379
- chatId: params.chatId,
380
- messageId: params.messageId,
381
- understanding: downloaded.understanding,
382
- error: String(err),
383
- });
384
- return undefined;
385
- }
386
- finally {
387
- void (async () => {
388
- try {
389
- const { rm } = await import("node:fs/promises");
390
- const { dirname } = await import("node:path");
391
- await rm(dirname(downloaded.path), { recursive: true, force: true });
392
- }
393
- catch {
394
- // Leaving a temp file behind is not worth failing a delivered message.
395
- }
396
- })();
397
- }
398
- }
258
+ const attachments_1 = require("./attachments");
399
259
  const createChannelPlugin = (runtimes, pluginRuntime) => {
400
260
  const resolveRuntimeAccountId = (cfg, preferred) => {
401
261
  const configured = (0, helpers_1.resolveConfiguredAccountId)(cfg, preferred);
@@ -407,6 +267,20 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
407
267
  }
408
268
  return configured ?? runtimes.keys().next().value;
409
269
  };
270
+ /**
271
+ * The connected runtime for an account, or a refusal naming it.
272
+ *
273
+ * One helper instead of the eleven copies of this three-liner that used to
274
+ * sit inside each dispatch branch — the same repetition that made every new
275
+ * action cost a scaffold (finding A6-11).
276
+ */
277
+ const requireRuntimeFor = (id) => {
278
+ const gram = runtimes.get(id);
279
+ if (!gram) {
280
+ throw new Error(`clawgram: runtime not found for account ${id}`);
281
+ }
282
+ return gram;
283
+ };
410
284
  return {
411
285
  id: "clawgram",
412
286
  meta: {
@@ -551,6 +425,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
551
425
  const gram = new gramjs_client_1.GramJsClientManager(resolvedAccount);
552
426
  await gram.start();
553
427
  runtimes.set(accountId, gram);
428
+ (0, system_notice_1.rememberOperatorIds)(accountId, resolveAccountOperatorIds(cfg, accountId));
429
+ // Область отправки — туда же и по той же причине: в `outbound.*`
430
+ // конфига нет, а барьер нужен и на пути доставки ядра (A5-12).
431
+ (0, send_scope_1.rememberSendScope)(accountId, resolveAccountSendChats(cfg, accountId));
432
+ warnAboutHandleAllowlistEntries(cfg, accountId);
554
433
  const pairing = (0, channel_pairing_1.createChannelPairingController)({
555
434
  // The controller only reads core.channel.pairing, but its parameter is typed
556
435
  // as the full PluginRuntime, and ctx (hence channelRuntime) is untyped.
@@ -687,7 +566,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
687
566
  // voice note and a screenshot alike, the attachment *is* the
688
567
  // message. A caption is kept and the reading appended, because
689
568
  // "look at this" plus the picture is one thought, not two.
690
- const attachment = senderMayReachAgent ? await readInboundAttachment({
569
+ const attachment = senderMayReachAgent ? await (0, attachments_1.readInboundAttachment)({
691
570
  gram,
692
571
  event,
693
572
  cfg,
@@ -956,7 +835,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
956
835
  replyToId: normalized.messageId,
957
836
  address: groupReplyAddress,
958
837
  });
959
- const messageThreadId = parseOptionalThreadId(normalized.messageThreadId);
838
+ const messageThreadId = (0, helpers_1.parseOptionalThreadId)(normalized.messageThreadId);
960
839
  const groupTypingTarget = normalized.chatId;
961
840
  await gram.withTyping(groupTypingTarget, async () => {
962
841
  log?.info?.("clawgram dispatching group reply", {
@@ -1033,6 +912,25 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1033
912
  });
1034
913
  return;
1035
914
  }
915
+ // Ядро подклеивает свою телеметрию к полезной нагрузке
916
+ // хода, и сюда она приходит тем же путём, что ответ.
917
+ // Проверка стояла только в `outbound.sendText`, то есть
918
+ // класс инцидента 30.08–01.09 был закрыт для рассылок и
919
+ // открыт для обычного ответа на упоминание (A5-10).
920
+ const groupNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
921
+ targetKind: "group",
922
+ text: visibleText,
923
+ });
924
+ if (groupNotice) {
925
+ log?.warn?.("clawgram suppressing system notice in group reply", {
926
+ accountId,
927
+ chatId: normalized.chatId,
928
+ messageId: normalized.messageId,
929
+ noticeKind: groupNotice,
930
+ textLength: visibleText.length,
931
+ });
932
+ return;
933
+ }
1036
934
  const replyToMessageId = payload.replyToId ? Number(payload.replyToId) : Number(normalized.messageId);
1037
935
  const rememberedAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
1038
936
  accountId: route.accountId ?? accountId,
@@ -1086,9 +984,24 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1086
984
  // `[[tts:text]]Привет, Вася!…[[/tts:text]]` verbatim. The
1087
985
  // spoken words are kept — a synthesis that did not happen
1088
986
  // should degrade to readable text, not to markup.
1089
- const visibleFallbackText = fallbackText
987
+ const rawFallback = fallbackText
1090
988
  ? (0, helpers_1.stripTtsDirectives)((0, helpers_1.stripSilentReplyToken)(fallbackText))
1091
989
  : "";
990
+ // Тот же фильтр и здесь: последняя реплика в стенограмме
991
+ // вполне может оказаться именно уведомлением об ошибке.
992
+ const fallbackNotice = rawFallback
993
+ ? (0, system_notice_1.shouldSuppressGroupSystemNotice)({ targetKind: "group", text: rawFallback })
994
+ : undefined;
995
+ if (fallbackNotice) {
996
+ log?.warn?.("clawgram suppressing system notice in transcript fallback", {
997
+ accountId,
998
+ chatId: normalized.chatId,
999
+ messageId: normalized.messageId,
1000
+ noticeKind: fallbackNotice,
1001
+ textLength: rawFallback.length,
1002
+ });
1003
+ }
1004
+ const visibleFallbackText = fallbackNotice ? "" : rawFallback;
1092
1005
  if (!visibleFallbackText) {
1093
1006
  if (fallbackText) {
1094
1007
  log?.info?.("clawgram skipping silent transcript fallback", {
@@ -1570,10 +1483,14 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1570
1483
  // Both count, because a rehearsal flag that is silently ignored puts
1571
1484
  // a real message in a real chat — twice, so far (2.13.1).
1572
1485
  const dryRun = (0, helpers_1.resolveDryRun)(dryRunFlag, params);
1486
+ // Every branch below compares the canonical name, so a spelling is
1487
+ // resolved once, here, and `ACTION_ALIASES` is the only place that
1488
+ // decides what a name means. An unknown name stays itself and falls
1489
+ // through to the unsupported-action error, as before.
1490
+ const canonical = (0, actions_1.canonicalAction)(action);
1573
1491
  // `read` is what OpenClaw core dispatches (`openclaw message read`,
1574
- // MCP `messages_read`). `list` is accepted as a synonym so a caller that
1575
- // guessed the other obvious name is not silently refused.
1576
- if (action === "read" || action === "list") {
1492
+ // MCP `messages_read`); `list` resolves to it too.
1493
+ if (canonical === "read") {
1577
1494
  const listParams = (0, history_1.parseListMessagesParams)(params);
1578
1495
  const listAccountId = resolveRuntimeAccountId(cfg, accountId);
1579
1496
  if (!listAccountId) {
@@ -1622,9 +1539,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1622
1539
  // channel and unreachable to the agent. Same `readChats` scope as
1623
1540
  // history: this must not become a way to pull bytes out of a chat the
1624
1541
  // account was never allowed to read.
1625
- if (action === "fetch-media" || action === "fetchMedia" ||
1626
- action === "download-media" || action === "downloadMedia" ||
1627
- action === "getMedia" || action === "download-file") {
1542
+ if (canonical === "fetch-media") {
1628
1543
  const fetchParams = (0, fetch_media_1.parseFetchMediaParams)(params);
1629
1544
  const fetchAccountId = resolveRuntimeAccountId(cfg, accountId);
1630
1545
  if (!fetchAccountId) {
@@ -1665,11 +1580,21 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1665
1580
  // the shared directory is keyed by chat and message, and deleting
1666
1581
  // that path would pull the file out from under an earlier `both`
1667
1582
  // fetch of the same message that handed the caller a path.
1668
- const sharedFetchDir = node_path_1.default.join(node_os_1.default.tmpdir(), "clawgram-fetched");
1583
+ // Не общий /tmp: там файлы видит каждый локальный пользователь, а на
1584
+ // этом хосте живёт ещё и раннер деплоя. Каталог состояния OpenClaw
1585
+ // принадлежит агенту; если он не задан, остаётся /tmp — но права
1586
+ // 0700/0600 ставятся в любом случае (A5-13).
1587
+ // Каталог состояния принадлежит агенту; при явно заданном
1588
+ // OPENCLAW_STATE_DIR вложения не покидают его.
1589
+ const mediaRoot = process.env.OPENCLAW_STATE_DIR?.trim()
1590
+ ? node_path_1.default.join((0, state_dir_1.resolveStateDir)(), "tmp")
1591
+ : node_os_1.default.tmpdir();
1592
+ const sharedFetchDir = node_path_1.default.join(mediaRoot, "clawgram-fetched");
1669
1593
  let fetchDir = sharedFetchDir;
1670
1594
  if (fetchParams.mode === "read") {
1671
- const { mkdtemp } = await import("node:fs/promises");
1672
- fetchDir = await mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "clawgram-media-"));
1595
+ const { mkdtemp, mkdir } = await import("node:fs/promises");
1596
+ await mkdir(mediaRoot, { recursive: true, mode: 0o700 });
1597
+ fetchDir = await mkdtemp(node_path_1.default.join(mediaRoot, "clawgram-media-"));
1673
1598
  }
1674
1599
  else {
1675
1600
  await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
@@ -1677,7 +1602,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1677
1602
  const downloaded = await (0, media_1.downloadMessageMediaToFile)({
1678
1603
  client: fetchGram.getClient(),
1679
1604
  message: found.message,
1680
- maxBytes: INBOUND_MEDIA_MAX_BYTES,
1605
+ maxBytes: attachments_1.INBOUND_MEDIA_MAX_BYTES,
1681
1606
  dir: fetchDir,
1682
1607
  fileNameFor: ({ media, extension }) => (0, fetch_media_1.fetchedMediaFileName)({
1683
1608
  chatId: fetchChatId,
@@ -1693,7 +1618,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1693
1618
  // large to be worth the transfer. Saying "could not fetch" to all
1694
1619
  // three is how "she ignored the picture" starts.
1695
1620
  const described = (0, media_1.describeMedia)(found.message?.media);
1696
- const tooLarge = typeof described?.size === "number" && described.size > INBOUND_MEDIA_MAX_BYTES;
1621
+ const tooLarge = typeof described?.size === "number" && described.size > attachments_1.INBOUND_MEDIA_MAX_BYTES;
1697
1622
  const error = !described
1698
1623
  ? "no-media"
1699
1624
  : tooLarge
@@ -1719,7 +1644,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1719
1644
  let readError;
1720
1645
  if (fetchParams.mode !== "file") {
1721
1646
  try {
1722
- read = await understandAttachmentFile({
1647
+ read = await (0, attachments_1.understandAttachmentFile)({
1723
1648
  runtime: pluginRuntime,
1724
1649
  cfg,
1725
1650
  filePath: downloaded.path,
@@ -1773,189 +1698,174 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1773
1698
  readError,
1774
1699
  });
1775
1700
  }
1776
- // Membership is a read, so the same `readChats` scope that gates history
1777
- // gates it too: this cannot become a way to enumerate chats the account
1778
- // was never allowed to read.
1779
- if (action === "participants" || action === "members" || action === "member-info") {
1780
- const participantsParams = (0, history_1.parseListParticipantsParams)(params);
1781
- const participantsAccountId = resolveRuntimeAccountId(cfg, accountId);
1782
- if (!participantsAccountId) {
1701
+ /**
1702
+ * The scaffold every chat-shaped read shares.
1703
+ *
1704
+ * `participants`, `topics`, `dialogs`, `joins` and `chatInfo` each
1705
+ * spelled out the same sequence: parse, resolve the account, check a
1706
+ * scope, fetch the runtime, call it, log counts, answer. Roughly
1707
+ * forty lines apiece, differing in four places — which is how a new
1708
+ * action came to cost sixty lines of scaffold and how the two gates
1709
+ * drifted apart (finding A6-11).
1710
+ *
1711
+ * The gate follows from the shape rather than being restated: an
1712
+ * action that names a chat is gated by `readChats`, `dialogs` has its
1713
+ * own discovery gate precisely because its point is to find chats
1714
+ * that are not in scope yet, and `joins` has none — the journal only
1715
+ * ever holds chats this account was put into.
1716
+ *
1717
+ * The runtime is a getter, not a value: `joins` reads a file and must
1718
+ * not fail merely because no runtime is connected.
1719
+ */
1720
+ const runRead = async (spec) => {
1721
+ const parsed = spec.parse();
1722
+ const readAccountId = resolveRuntimeAccountId(cfg, accountId);
1723
+ if (!readAccountId) {
1783
1724
  throw new Error("clawgram: no configured account found");
1784
1725
  }
1785
- if (!(0, history_1.isChatReadable)(participantsParams.target, resolveAccountReadChats(cfg, participantsAccountId))) {
1786
- actionLog.warn("clawgram participants refused: chat outside read scope", {
1787
- accountId: participantsAccountId,
1788
- target: participantsParams.target,
1789
- });
1790
- throw new Error(`clawgram: not-allowed-chat ${participantsParams.target}`);
1726
+ const target = spec.target?.(parsed);
1727
+ if (target !== undefined) {
1728
+ if (!(0, history_1.isChatReadable)(target, resolveAccountReadChats(cfg, readAccountId))) {
1729
+ actionLog.warn(`clawgram ${spec.name} refused: chat outside read scope`, {
1730
+ accountId: readAccountId,
1731
+ target,
1732
+ });
1733
+ throw new Error(`clawgram: not-allowed-chat ${target}`);
1734
+ }
1791
1735
  }
1792
- const participantsGram = runtimes.get(participantsAccountId);
1793
- if (!participantsGram) {
1794
- throw new Error(`clawgram: runtime not found for account ${participantsAccountId}`);
1736
+ else if (spec.discovery) {
1737
+ if (!(0, dialogs_1.isChatDiscoveryEnabled)(resolveAccountDiscoverChats(cfg, readAccountId))) {
1738
+ actionLog.warn(`clawgram ${spec.name} refused: chat-discovery is not enabled`, {
1739
+ accountId: readAccountId,
1740
+ });
1741
+ throw new Error("clawgram: chat-discovery is not enabled");
1742
+ }
1795
1743
  }
1796
- const membership = await participantsGram.listParticipants(participantsParams);
1797
- // Counts only. Member ids are personal data and have no business in a
1798
- // log that is read while debugging something else.
1799
- actionLog.info("clawgram handleAction participants completed", {
1800
- accountId: participantsAccountId,
1801
- target: participantsParams.target,
1802
- limit: participantsParams.limit,
1803
- returned: membership.participants.length,
1804
- truncated: membership.truncated,
1744
+ const gram = () => requireRuntimeFor(readAccountId);
1745
+ const result = await spec.run({ parsed, accountId: readAccountId, gram });
1746
+ actionLog.info(`clawgram handleAction ${spec.name} completed`, {
1747
+ accountId: readAccountId,
1748
+ ...spec.after(parsed, result),
1805
1749
  });
1806
- return (0, core_1.jsonResult)({
1807
- ok: true,
1808
- accountId: participantsAccountId,
1809
- chatId: membership.chatId ?? participantsParams.target,
1810
- count: membership.participants.length,
1811
- truncated: membership.truncated,
1812
- participants: membership.participants,
1750
+ return (0, core_1.jsonResult)({ ok: true, accountId: readAccountId, ...spec.result(parsed, result) });
1751
+ };
1752
+ // Membership is a read, so the same `readChats` scope that gates history
1753
+ // gates it too: this cannot become a way to enumerate chats the account
1754
+ // was never allowed to read.
1755
+ if (canonical === "participants") {
1756
+ return await runRead({
1757
+ name: "participants",
1758
+ parse: () => (0, history_1.parseListParticipantsParams)(params),
1759
+ target: (p) => p.target,
1760
+ run: ({ parsed, gram }) => gram().listParticipants(parsed),
1761
+ // Counts only. Member ids are personal data and have no business in
1762
+ // a log that is read while debugging something else.
1763
+ after: (p, m) => ({
1764
+ target: p.target,
1765
+ limit: p.limit,
1766
+ returned: m.participants.length,
1767
+ truncated: m.truncated,
1768
+ }),
1769
+ result: (p, m) => ({
1770
+ chatId: m.chatId ?? p.target,
1771
+ count: m.participants.length,
1772
+ truncated: m.truncated,
1773
+ participants: m.participants,
1774
+ }),
1813
1775
  });
1814
1776
  }
1815
1777
  // Topic names. A forum chat is addressed by topic id, and until now an
1816
1778
  // id could only be lifted off an inbound message — so a topic nobody had
1817
1779
  // written in yet was unreachable, and one named in words was unfindable.
1818
1780
  // Titles say what a chat is working on, so the read scope gates them.
1819
- if (action === "topics" || action === "forumTopics" || action === "thread-list") {
1820
- const topicsParams = (0, topics_1.parseTopicsParams)(params);
1821
- const topicsAccountId = resolveRuntimeAccountId(cfg, accountId);
1822
- if (!topicsAccountId) {
1823
- throw new Error("clawgram: no configured account found");
1824
- }
1825
- if (!(0, history_1.isChatReadable)(topicsParams.target, resolveAccountReadChats(cfg, topicsAccountId))) {
1826
- actionLog.warn("clawgram topics refused: chat outside read scope", {
1827
- accountId: topicsAccountId,
1828
- target: topicsParams.target,
1829
- });
1830
- throw new Error(`clawgram: not-allowed-chat ${topicsParams.target}`);
1831
- }
1832
- const topicsGram = runtimes.get(topicsAccountId);
1833
- if (!topicsGram) {
1834
- throw new Error(`clawgram: runtime not found for account ${topicsAccountId}`);
1835
- }
1836
- const forum = await topicsGram.listTopics(topicsParams);
1837
- actionLog.info("clawgram handleAction topics completed", {
1838
- accountId: topicsAccountId,
1839
- target: topicsParams.target,
1840
- limit: topicsParams.limit,
1841
- returned: forum.topics.length,
1842
- truncated: forum.truncated,
1843
- });
1844
- return (0, core_1.jsonResult)({
1845
- ok: true,
1846
- accountId: topicsAccountId,
1847
- chatId: forum.chatId ?? topicsParams.target,
1848
- count: forum.topics.length,
1849
- truncated: forum.truncated,
1850
- topics: forum.topics,
1781
+ if (canonical === "topics") {
1782
+ return await runRead({
1783
+ name: "topics",
1784
+ parse: () => (0, topics_1.parseTopicsParams)(params),
1785
+ target: (p) => p.target,
1786
+ run: ({ parsed, gram }) => gram().listTopics(parsed),
1787
+ after: (p, f) => ({
1788
+ target: p.target,
1789
+ limit: p.limit,
1790
+ returned: f.topics.length,
1791
+ truncated: f.truncated,
1792
+ }),
1793
+ result: (p, f) => ({
1794
+ chatId: f.chatId ?? p.target,
1795
+ count: f.topics.length,
1796
+ truncated: f.truncated,
1797
+ topics: f.topics,
1798
+ }),
1851
1799
  });
1852
1800
  }
1853
1801
  // Which chats this account is in. Not gated by `readChats` — the whole
1854
1802
  // point is to find chats that are not in it yet — so it has a gate of
1855
1803
  // its own, is metadata only, and never reports direct chats.
1856
- if (action === "dialogs" || action === "chats" || action === "channel-list") {
1857
- const dialogsParams = (0, dialogs_1.parseDialogsParams)(params);
1858
- const dialogsAccountId = resolveRuntimeAccountId(cfg, accountId);
1859
- if (!dialogsAccountId) {
1860
- throw new Error("clawgram: no configured account found");
1861
- }
1862
- if (!(0, dialogs_1.isChatDiscoveryEnabled)(resolveAccountDiscoverChats(cfg, dialogsAccountId))) {
1863
- actionLog.warn("clawgram dialogs refused: chat-discovery is not enabled", {
1864
- accountId: dialogsAccountId,
1865
- });
1866
- throw new Error("clawgram: chat-discovery is not enabled");
1867
- }
1868
- const dialogsGram = runtimes.get(dialogsAccountId);
1869
- if (!dialogsGram) {
1870
- throw new Error(`clawgram: runtime not found for account ${dialogsAccountId}`);
1871
- }
1872
- const found = await dialogsGram.listDialogs(dialogsParams);
1873
- // Counts only: which chats a person's account sits in is exactly the
1874
- // kind of thing that should not be sitting in a log.
1875
- actionLog.info("clawgram handleAction dialogs completed", {
1876
- accountId: dialogsAccountId,
1877
- limit: dialogsParams.limit,
1878
- returned: found.dialogs.length,
1879
- truncated: found.truncated,
1880
- });
1881
- return (0, core_1.jsonResult)({
1882
- ok: true,
1883
- accountId: dialogsAccountId,
1884
- count: found.dialogs.length,
1885
- truncated: found.truncated,
1886
- dialogs: found.dialogs,
1804
+ if (canonical === "dialogs") {
1805
+ return await runRead({
1806
+ name: "dialogs",
1807
+ parse: () => (0, dialogs_1.parseDialogsParams)(params),
1808
+ discovery: true,
1809
+ run: ({ parsed, gram }) => gram().listDialogs(parsed),
1810
+ // Counts only: which chats a person's account sits in is exactly
1811
+ // the kind of thing that should not be sitting in a log.
1812
+ after: (p, f) => ({ limit: p.limit, returned: f.dialogs.length, truncated: f.truncated }),
1813
+ result: (_p, f) => ({ count: f.dialogs.length, truncated: f.truncated, dialogs: f.dialogs }),
1887
1814
  });
1888
1815
  }
1889
1816
  // Where this account was recently added, and by whom. Reading the journal
1890
1817
  // has no scope check of its own: it only ever contains chats this account
1891
1818
  // was put into, which is exactly what the caller is allowed to learn.
1892
- if (action === "joins") {
1893
- const joinsParams = (0, joins_1.parseJoinsParams)(params);
1894
- const joinsAccountId = resolveRuntimeAccountId(cfg, accountId);
1895
- if (!joinsAccountId) {
1896
- throw new Error("clawgram: no configured account found");
1897
- }
1898
- const journalPath = (0, joins_1.resolveJoinsJournalPath)(cfg?.channels?.["clawgram"]?.accounts?.[joinsAccountId], joinsAccountId);
1899
- const selected = (0, joins_1.selectJoinRecords)((0, joins_1.readJoinRecords)(journalPath), joinsParams);
1900
- actionLog.info("clawgram handleAction joins completed", {
1901
- accountId: joinsAccountId,
1902
- since: joinsParams.since ?? null,
1903
- limit: joinsParams.limit,
1904
- returned: selected.length,
1905
- });
1906
- return (0, core_1.jsonResult)({
1907
- ok: true,
1908
- accountId: joinsAccountId,
1909
- count: selected.length,
1910
- joins: selected,
1819
+ if (canonical === "joins") {
1820
+ return await runRead({
1821
+ name: "joins",
1822
+ parse: () => (0, joins_1.parseJoinsParams)(params),
1823
+ // No runtime: this reads a file, and must answer with none connected.
1824
+ run: async ({ parsed, accountId: joinsAccountId }) => (0, joins_1.selectJoinRecords)((0, joins_1.readJoinRecords)((0, joins_1.resolveJoinsJournalPath)(cfg?.channels?.["clawgram"]?.accounts?.[joinsAccountId], joinsAccountId)), parsed),
1825
+ after: (p, selected) => ({
1826
+ since: p.since ?? null,
1827
+ limit: p.limit,
1828
+ returned: selected.length,
1829
+ }),
1830
+ result: (_p, selected) => ({ count: selected.length, joins: selected }),
1911
1831
  });
1912
1832
  }
1913
1833
  // Describing a chat is a read, so the same `readChats` scope that gates
1914
1834
  // history gates it too — this must not become a way to learn the title
1915
1835
  // and size of a chat the account was never allowed to read.
1916
- if (action === "chatInfo" || action === "getChatInfo" || action === "channel-info"
1917
- || action === "chatMetadata" || action === "getChatMetadata") {
1918
- const chatInfoParams = (0, chat_info_1.parseChatInfoParams)(params, toolContext);
1919
- const chatInfoAccountId = resolveRuntimeAccountId(cfg, accountId);
1920
- if (!chatInfoAccountId) {
1921
- throw new Error("clawgram: no configured account found");
1922
- }
1923
- if (!(0, history_1.isChatReadable)(chatInfoParams.target, resolveAccountReadChats(cfg, chatInfoAccountId))) {
1924
- actionLog.warn("clawgram chatInfo refused: chat outside read scope", {
1925
- accountId: chatInfoAccountId,
1926
- target: chatInfoParams.target,
1927
- });
1928
- throw new Error(`clawgram: not-allowed-chat ${chatInfoParams.target}`);
1929
- }
1930
- const chatInfoGram = runtimes.get(chatInfoAccountId);
1931
- if (!chatInfoGram) {
1932
- throw new Error(`clawgram: runtime not found for account ${chatInfoAccountId}`);
1933
- }
1934
- const { entity, full } = await chatInfoGram.getChatInfo(chatInfoParams.target);
1935
- const info = (0, chat_info_1.describeChat)(entity, full);
1936
- // Type and size only. The title of a private chat is as personal as
1937
- // its contents and has no business in a debugging log.
1938
- actionLog.info("clawgram handleAction chatInfo completed", {
1939
- accountId: chatInfoAccountId,
1940
- type: info.type,
1941
- memberCount: info.memberCount ?? null,
1942
- isForum: info.isForum ?? null,
1943
- });
1944
- return (0, core_1.jsonResult)({
1945
- ok: true,
1946
- accountId: chatInfoAccountId,
1947
- chat: { ...info, chatId: info.chatId ?? chatInfoParams.target },
1836
+ if (canonical === "chatInfo") {
1837
+ return await runRead({
1838
+ name: "chatInfo",
1839
+ parse: () => (0, chat_info_1.parseChatInfoParams)(params, toolContext),
1840
+ target: (p) => p.target,
1841
+ run: async ({ parsed, gram }) => {
1842
+ const { entity, full } = await gram().getChatInfo(parsed.target);
1843
+ return (0, chat_info_1.describeChat)(entity, full);
1844
+ },
1845
+ // Type and size only. The title of a private chat is as personal as
1846
+ // its contents and has no business in a debugging log.
1847
+ after: (_p, info) => ({
1848
+ type: info.type,
1849
+ memberCount: info.memberCount ?? null,
1850
+ isForum: info.isForum ?? null,
1851
+ }),
1852
+ result: (p, info) => ({ chat: { ...info, chatId: info.chatId ?? p.target } }),
1948
1853
  });
1949
1854
  }
1950
1855
  // A reaction is an outbound act on someone else's message, so it is
1951
1856
  // gated like sending rather than like reading — and it respects
1952
1857
  // `dryRun`, which reading does not need to.
1953
- if (action === "react") {
1858
+ if (canonical === "react") {
1954
1859
  const reactionParams = (0, reactions_1.parseReactionParams)(params, toolContext);
1955
1860
  const reactionAccountId = resolveRuntimeAccountId(cfg, accountId);
1956
1861
  if (!reactionAccountId) {
1957
1862
  throw new Error("clawgram: no configured account found");
1958
1863
  }
1864
+ // Реакция — видимое действие от имени владельца в чужом чате, и
1865
+ // адресуется она так же, как сообщение: та же область (A5-12).
1866
+ if (!(0, send_scope_1.isChatSendable)(reactionParams.target, resolveAccountSendChats(cfg, reactionAccountId))) {
1867
+ refuseOutboundOutsideScope("react", reactionAccountId, String(reactionParams.target));
1868
+ }
1959
1869
  actionLog.info("clawgram handleAction react", {
1960
1870
  accountId: reactionAccountId,
1961
1871
  dryRun: dryRun === true,
@@ -2000,15 +1910,43 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2000
1910
  // returns after the gate so a dry run exercises the same refusals a
2001
1911
  // real call would hit. People's ids stay out of the logs throughout;
2002
1912
  // the JSON result carries them to the caller, the journal does not.
2003
- const manageAction = MANAGE_ACTION_ALIASES[action];
1913
+ const manageAction = actions_1.MANAGE_ACTIONS.has(canonical) ? canonical : undefined;
2004
1914
  if (manageAction) {
2005
1915
  const manageAccountId = resolveRuntimeAccountId(cfg, accountId);
2006
1916
  if (!manageAccountId) {
2007
1917
  throw new Error("clawgram: no configured account found");
2008
1918
  }
2009
1919
  const manageScope = resolveAccountManageChats(cfg, manageAccountId);
2010
- const requireManagedChat = (target) => {
2011
- if (!(0, manage_1.isChatManageable)(target, manageScope)) {
1920
+ const requireRuntime = () => requireRuntimeFor(manageAccountId);
1921
+ /**
1922
+ * The scaffold every management action shares.
1923
+ *
1924
+ * Six actions used to spell it out one after another: resolve the
1925
+ * account, check the scope, log, answer a dry run, call the
1926
+ * runtime, log again, build the result. A change to any of those —
1927
+ * the dry-run contract, say — was a six-place edit in the plugin's
1928
+ * largest file, and the one deliberate exception (createGroup does
1929
+ * not check a chat scope, because the chat does not exist yet) was
1930
+ * invisible among the copies (finding A12-06).
1931
+ *
1932
+ * The differences stay written at each call site: what to parse,
1933
+ * what to log, what to run, what to answer. Only the scaffold moved.
1934
+ */
1935
+ const runManage = async (spec) => {
1936
+ const parsed = spec.parse();
1937
+ const target = spec.target(parsed);
1938
+ if (target === undefined) {
1939
+ // Nothing to check a scope against yet, so the gate is coarser:
1940
+ // management must be enabled at all for this account.
1941
+ if (!(0, manage_1.isManagementEnabled)(manageScope)) {
1942
+ actionLog.warn(`clawgram ${spec.name} refused: management is not enabled`, {
1943
+ accountId: manageAccountId,
1944
+ });
1945
+ throw new Error("clawgram: chat management is not enabled for this account — "
1946
+ + `set channels.clawgram.accounts.${manageAccountId}.manageChats`);
1947
+ }
1948
+ }
1949
+ else if (!(0, manage_1.isChatManageable)(target, manageScope)) {
2012
1950
  actionLog.warn("clawgram management refused: chat outside manage scope", {
2013
1951
  accountId: manageAccountId,
2014
1952
  action: manageAction,
@@ -2016,188 +1954,126 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2016
1954
  });
2017
1955
  throw new Error(`clawgram: not-managed-chat ${target}`);
2018
1956
  }
2019
- };
2020
- const requireRuntime = () => {
2021
- const gram = runtimes.get(manageAccountId);
2022
- if (!gram) {
2023
- throw new Error(`clawgram: runtime not found for account ${manageAccountId}`);
2024
- }
2025
- return gram;
2026
- };
2027
- if (manageAction === "createGroup") {
2028
- const createParams = (0, manage_1.parseCreateGroupParams)(params);
2029
- // A group being created is not in any scope yet, so the gate is
2030
- // coarser: management must be enabled at all for this account.
2031
- if (!(0, manage_1.isManagementEnabled)(manageScope)) {
2032
- actionLog.warn("clawgram createGroup refused: management is not enabled", {
2033
- accountId: manageAccountId,
2034
- });
2035
- throw new Error("clawgram: chat management is not enabled for this account — "
2036
- + `set channels.clawgram.accounts.${manageAccountId}.manageChats`);
2037
- }
2038
- actionLog.info("clawgram handleAction createGroup", {
1957
+ actionLog.info(`clawgram handleAction ${spec.name}`, {
2039
1958
  accountId: manageAccountId,
2040
1959
  dryRun: dryRun === true,
2041
- users: createParams.users.length,
2042
- hasAbout: Boolean(createParams.about),
1960
+ ...spec.before(parsed),
2043
1961
  });
2044
1962
  if (dryRun === true) {
2045
- return (0, core_1.jsonResult)({ ok: true, dryRun: true, accountId: manageAccountId });
1963
+ return (0, core_1.jsonResult)({
1964
+ ok: true,
1965
+ dryRun: true,
1966
+ accountId: manageAccountId,
1967
+ ...(target === undefined ? {} : { chatId: target }),
1968
+ });
2046
1969
  }
2047
- const created = await requireRuntime().createGroup(createParams);
2048
- actionLog.info("clawgram handleAction createGroup completed", {
1970
+ const gram = requireRuntime();
1971
+ spec.precondition?.(gram);
1972
+ const result = await spec.run(gram, parsed);
1973
+ actionLog.info(`clawgram handleAction ${spec.name} completed`, {
2049
1974
  accountId: manageAccountId,
2050
- chatId: created.chatId ?? null,
2051
- missing: created.missing.length,
1975
+ ...spec.after(parsed, result),
2052
1976
  });
2053
- return (0, core_1.jsonResult)({
2054
- ok: true,
2055
- accountId: manageAccountId,
2056
- chatId: created.chatId,
2057
- missing: created.missing,
1977
+ return (0, core_1.jsonResult)({ ok: true, accountId: manageAccountId, ...spec.result(parsed, result) });
1978
+ };
1979
+ if (manageAction === "createGroup") {
1980
+ return await runManage({
1981
+ name: "createGroup",
1982
+ parse: () => (0, manage_1.parseCreateGroupParams)(params),
1983
+ // A group being created is not in any scope yet.
1984
+ target: () => undefined,
1985
+ before: (p) => ({ users: p.users.length, hasAbout: Boolean(p.about) }),
1986
+ run: (gram, p) => gram.createGroup(p),
1987
+ after: (_p, created) => ({ chatId: created.chatId ?? null, missing: created.missing.length }),
1988
+ result: (_p, created) => ({ chatId: created.chatId, missing: created.missing }),
2058
1989
  });
2059
1990
  }
2060
1991
  if (manageAction === "addMembers") {
2061
- const addParams = (0, manage_1.parseAddMembersParams)(params, toolContext);
2062
- requireManagedChat(addParams.target);
2063
- actionLog.info("clawgram handleAction addMembers", {
2064
- accountId: manageAccountId,
2065
- dryRun: dryRun === true,
2066
- target: addParams.target,
2067
- users: addParams.users.length,
2068
- });
2069
- if (dryRun === true) {
2070
- return (0, core_1.jsonResult)({ ok: true, dryRun: true, accountId: manageAccountId, chatId: addParams.target });
2071
- }
2072
- const added = await requireRuntime().addChatMembers(addParams);
2073
- actionLog.info("clawgram handleAction addMembers completed", {
2074
- accountId: manageAccountId,
2075
- target: addParams.target,
2076
- requested: addParams.users.length,
2077
- missing: added.missing.length,
2078
- });
2079
- return (0, core_1.jsonResult)({
2080
- ok: true,
2081
- accountId: manageAccountId,
2082
- chatId: added.chatId ?? addParams.target,
2083
- requested: addParams.users.length,
2084
- // Telegram refuses silently-restricted invites per user; the
2085
- // caller gets the ids so it can hand them an invite link.
2086
- missing: added.missing,
1992
+ return await runManage({
1993
+ name: "addMembers",
1994
+ parse: () => (0, manage_1.parseAddMembersParams)(params, toolContext),
1995
+ target: (p) => p.target,
1996
+ before: (p) => ({ target: p.target, users: p.users.length }),
1997
+ run: (gram, p) => gram.addChatMembers(p),
1998
+ after: (p, added) => ({
1999
+ target: p.target,
2000
+ requested: p.users.length,
2001
+ missing: added.missing.length,
2002
+ }),
2003
+ result: (p, added) => ({
2004
+ chatId: added.chatId ?? p.target,
2005
+ requested: p.users.length,
2006
+ // Telegram refuses silently-restricted invites per user; the
2007
+ // caller gets the ids so it can hand them an invite link.
2008
+ missing: added.missing,
2009
+ }),
2087
2010
  });
2088
2011
  }
2089
2012
  if (manageAction === "removeMember") {
2090
- const removeParams = (0, manage_1.parseRemoveMemberParams)(params, toolContext);
2091
- requireManagedChat(removeParams.target);
2092
- actionLog.info("clawgram handleAction removeMember", {
2093
- accountId: manageAccountId,
2094
- dryRun: dryRun === true,
2095
- target: removeParams.target,
2096
- ban: removeParams.ban,
2097
- });
2098
- if (dryRun === true) {
2099
- return (0, core_1.jsonResult)({ ok: true, dryRun: true, accountId: manageAccountId, chatId: removeParams.target });
2100
- }
2101
- await requireRuntime().removeChatMember(removeParams);
2102
- actionLog.info("clawgram handleAction removeMember completed", {
2103
- accountId: manageAccountId,
2104
- target: removeParams.target,
2105
- ban: removeParams.ban,
2106
- });
2107
- return (0, core_1.jsonResult)({
2108
- ok: true,
2109
- accountId: manageAccountId,
2110
- chatId: removeParams.target,
2111
- user: removeParams.user,
2112
- banned: removeParams.ban,
2013
+ return await runManage({
2014
+ name: "removeMember",
2015
+ parse: () => (0, manage_1.parseRemoveMemberParams)(params, toolContext),
2016
+ target: (p) => p.target,
2017
+ before: (p) => ({ target: p.target, ban: p.ban }),
2018
+ run: (gram, p) => gram.removeChatMember(p),
2019
+ after: (p) => ({ target: p.target, ban: p.ban }),
2020
+ result: (p) => ({ chatId: p.target, user: p.user, banned: p.ban }),
2113
2021
  });
2114
2022
  }
2115
2023
  if (manageAction === "promoteAdmin" || manageAction === "demoteAdmin") {
2116
- const adminParams = manageAction === "promoteAdmin"
2117
- ? (0, manage_1.parsePromoteAdminParams)(params, toolContext)
2118
- : (0, manage_1.parseDemoteAdminParams)(params, toolContext);
2119
- requireManagedChat(adminParams.target);
2120
- actionLog.info("clawgram handleAction setAdmin", {
2121
- accountId: manageAccountId,
2122
- dryRun: dryRun === true,
2123
- target: adminParams.target,
2124
- isAdmin: adminParams.isAdmin,
2125
- hasRank: Boolean(adminParams.rank),
2126
- });
2127
- if (dryRun === true) {
2128
- return (0, core_1.jsonResult)({ ok: true, dryRun: true, accountId: manageAccountId, chatId: adminParams.target });
2129
- }
2130
- await requireRuntime().setChatAdmin(adminParams);
2131
- actionLog.info("clawgram handleAction setAdmin completed", {
2132
- accountId: manageAccountId,
2133
- target: adminParams.target,
2134
- isAdmin: adminParams.isAdmin,
2135
- });
2136
- return (0, core_1.jsonResult)({
2137
- ok: true,
2138
- accountId: manageAccountId,
2139
- chatId: adminParams.target,
2140
- user: adminParams.user,
2141
- isAdmin: adminParams.isAdmin,
2142
- ...(adminParams.rank ? { rank: adminParams.rank } : {}),
2024
+ const promote = manageAction === "promoteAdmin";
2025
+ return await runManage({
2026
+ // Both spellings log as `setAdmin`, as they always have.
2027
+ name: "setAdmin",
2028
+ parse: () => (promote
2029
+ ? (0, manage_1.parsePromoteAdminParams)(params, toolContext)
2030
+ : (0, manage_1.parseDemoteAdminParams)(params, toolContext)),
2031
+ target: (p) => p.target,
2032
+ before: (p) => ({ target: p.target, isAdmin: p.isAdmin, hasRank: Boolean(p.rank) }),
2033
+ run: (gram, p) => gram.setChatAdmin(p),
2034
+ after: (p) => ({ target: p.target, isAdmin: p.isAdmin }),
2035
+ result: (p) => ({
2036
+ chatId: p.target,
2037
+ user: p.user,
2038
+ isAdmin: p.isAdmin,
2039
+ ...(p.rank ? { rank: p.rank } : {}),
2040
+ }),
2143
2041
  });
2144
2042
  }
2145
2043
  if (manageAction === "transferOwnership") {
2146
- const transferParams = (0, manage_1.parseTransferOwnershipParams)(params, toolContext);
2147
- requireManagedChat(transferParams.target);
2148
- actionLog.info("clawgram handleAction transferOwnership", {
2149
- accountId: manageAccountId,
2150
- dryRun: dryRun === true,
2151
- target: transferParams.target,
2152
- });
2153
- if (dryRun === true) {
2154
- return (0, core_1.jsonResult)({ ok: true, dryRun: true, accountId: manageAccountId, chatId: transferParams.target });
2155
- }
2156
- const transferGram = requireRuntime();
2157
- // The password stays inside the runtime: it is read from the
2158
- // account config at start-up and never travels through dispatch
2159
- // arguments, which are one log call away from the journal.
2160
- if (!transferGram.twoFaPassword) {
2161
- throw new Error("clawgram: ownership transfer requires twoFaPassword in the account config "
2162
- + "(the account's Telegram 2FA password, as a literal or a SecretRef)");
2163
- }
2164
- await transferGram.transferChatOwnership(transferParams);
2165
- actionLog.info("clawgram handleAction transferOwnership completed", {
2166
- accountId: manageAccountId,
2167
- target: transferParams.target,
2168
- });
2169
- return (0, core_1.jsonResult)({
2170
- ok: true,
2171
- accountId: manageAccountId,
2172
- chatId: transferParams.target,
2173
- newOwner: transferParams.user,
2044
+ return await runManage({
2045
+ name: "transferOwnership",
2046
+ parse: () => (0, manage_1.parseTransferOwnershipParams)(params, toolContext),
2047
+ target: (p) => p.target,
2048
+ before: (p) => ({ target: p.target }),
2049
+ // The password stays inside the runtime: it is read from the
2050
+ // account config at start-up and never travels through dispatch
2051
+ // arguments, which are one log call away from the journal.
2052
+ precondition: (gram) => {
2053
+ if (!gram.twoFaPassword) {
2054
+ throw new Error("clawgram: ownership transfer requires twoFaPassword in the account config "
2055
+ + "(the account's Telegram 2FA password, as a literal or a SecretRef)");
2056
+ }
2057
+ },
2058
+ run: (gram, p) => gram.transferChatOwnership(p),
2059
+ after: (p) => ({ target: p.target }),
2060
+ result: (p) => ({ chatId: p.target, newOwner: p.user }),
2174
2061
  });
2175
2062
  }
2176
2063
  // inviteLink — the only management action left.
2177
- const inviteParams = (0, manage_1.parseInviteLinkParams)(params, toolContext);
2178
- requireManagedChat(inviteParams.target);
2179
- actionLog.info("clawgram handleAction inviteLink", {
2180
- accountId: manageAccountId,
2181
- dryRun: dryRun === true,
2182
- target: inviteParams.target,
2183
- hasExpiry: inviteParams.expireDate !== undefined,
2184
- usageLimit: inviteParams.usageLimit ?? null,
2185
- requestNeeded: inviteParams.requestNeeded,
2186
- });
2187
- if (dryRun === true) {
2188
- return (0, core_1.jsonResult)({ ok: true, dryRun: true, accountId: manageAccountId, chatId: inviteParams.target });
2189
- }
2190
- const exported = await requireRuntime().exportChatInviteLink(inviteParams);
2191
- actionLog.info("clawgram handleAction inviteLink completed", {
2192
- accountId: manageAccountId,
2193
- target: inviteParams.target,
2194
- hasLink: Boolean(exported.link),
2195
- });
2196
- return (0, core_1.jsonResult)({
2197
- ok: true,
2198
- accountId: manageAccountId,
2199
- chatId: inviteParams.target,
2200
- link: exported.link,
2064
+ return await runManage({
2065
+ name: "inviteLink",
2066
+ parse: () => (0, manage_1.parseInviteLinkParams)(params, toolContext),
2067
+ target: (p) => p.target,
2068
+ before: (p) => ({
2069
+ target: p.target,
2070
+ hasExpiry: p.expireDate !== undefined,
2071
+ usageLimit: p.usageLimit ?? null,
2072
+ requestNeeded: p.requestNeeded,
2073
+ }),
2074
+ run: (gram, p) => gram.exportChatInviteLink(p),
2075
+ after: (p, exported) => ({ target: p.target, hasLink: Boolean(exported.link) }),
2076
+ result: (p, exported) => ({ chatId: p.target, link: exported.link }),
2201
2077
  });
2202
2078
  }
2203
2079
  // Core normalizes whichever of these it filled in to a local path (see
@@ -2211,13 +2087,17 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2211
2087
  // and arrives from older callers. A plain `send` carrying a file lands
2212
2088
  // here too — `openclaw message send --media` does exactly that, and
2213
2089
  // routing it to the text path dropped the file without a word.
2214
- if (action === "upload-file" || action === "sendAttachment" || (action === "send" && attachedFile)) {
2090
+ if (canonical === "upload-file" || (canonical === "send" && attachedFile)) {
2215
2091
  const rawUploadTo = (0, helpers_1.resolveActionTarget)(params, toolContext);
2216
2092
  const uploadTo = (0, helpers_1.normalizeOutboundTarget)(rawUploadTo);
2217
2093
  const uploadAccountId = resolveRuntimeAccountId(cfg, accountId);
2218
2094
  if (!uploadAccountId) {
2219
2095
  throw new Error("clawgram: no configured account found");
2220
2096
  }
2097
+ // Та же граница, что у `send`: файл наружу — такое же исходящее.
2098
+ if (!(0, send_scope_1.isChatSendable)(uploadTo, resolveAccountSendChats(cfg, uploadAccountId))) {
2099
+ refuseOutboundOutsideScope("upload-file", uploadAccountId, uploadTo);
2100
+ }
2221
2101
  const file = attachedFile;
2222
2102
  if (!file) {
2223
2103
  throw new Error("clawgram: upload-file requires filePath, path, media, or mediaUrl");
@@ -2264,7 +2144,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2264
2144
  // the same prose as a message and renders identically.
2265
2145
  parseMode: (0, helpers_1.resolveOutboundParseMode)(params, cfg, uploadAccountId),
2266
2146
  replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(rawUploadTo, uploadReplyToId),
2267
- messageThreadId: parseOptionalThreadId(uploadThreadId),
2147
+ messageThreadId: (0, helpers_1.parseOptionalThreadId)(uploadThreadId),
2268
2148
  asVoice,
2269
2149
  });
2270
2150
  actionLog.info("clawgram handleAction upload-file completed", {
@@ -2287,7 +2167,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2287
2167
  const to = (0, helpers_1.normalizeOutboundTarget)(rawTo);
2288
2168
  const replyToId = (0, param_readers_1.readStringOrNumberParam)(params, "replyToId") ?? (0, param_readers_1.readStringOrNumberParam)(params, "replyTo");
2289
2169
  const threadId = (0, param_readers_1.readStringOrNumberParam)(params, "threadId");
2290
- const messageThreadId = parseOptionalThreadId(threadId);
2170
+ const messageThreadId = (0, helpers_1.parseOptionalThreadId)(threadId);
2291
2171
  // Omitting parseMode inherits the account's configured mode rather
2292
2172
  // than falling back to plain text (2.13.0): an account set to `html`
2293
2173
  // used to render replies as HTML and these sends as raw markup.
@@ -2307,6 +2187,12 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2307
2187
  if (!resolvedAccountId) {
2308
2188
  throw new Error("clawgram: no configured account found");
2309
2189
  }
2190
+ // Проверка ПОСЛЕ резолва аккаунта и ДО любой доставки: область задаётся
2191
+ // на аккаунт, а отказ должен случиться раньше, чем цель разрешена в
2192
+ // Telegram-сущность — resolve сам по себе виден собеседнику (A5-12).
2193
+ if (!(0, send_scope_1.isChatSendable)(to, resolveAccountSendChats(cfg, resolvedAccountId))) {
2194
+ refuseOutboundOutsideScope("send", resolvedAccountId, to);
2195
+ }
2310
2196
  const currentChannelId = toolContext?.currentChannelId?.trim() ?? "";
2311
2197
  const currentMessageId = toolContext?.currentMessageId;
2312
2198
  const currentChannelTarget = currentChannelId ? (0, helpers_1.normalizeOutboundTarget)(currentChannelId) : "";
@@ -2433,187 +2319,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2433
2319
  });
2434
2320
  },
2435
2321
  },
2436
- outbound: {
2437
- // Core's agent-delivery path (`--deliver`, subagent announces) calls this
2438
- // hook under three constraints, all learned live on 2026-08-06:
2439
- //
2440
- // - `to` may be undefined (no explicit target, session route yielded
2441
- // none), and a rejection is NOT caught: a throw here is an unhandled
2442
- // rejection that takes down the entire gateway process.
2443
- // - `resolveAgentDeliveryPlanWithSessionRoute` calls it WITHOUT await.
2444
- // An async hook hands core a Promise, `promise.ok` reads undefined and
2445
- // the error branch dereferences `promise.error.message` — the crash
2446
- // every subagent announce died on. The hook must return a plain value;
2447
- // the call sites that do await are unaffected, await of a value works.
2448
- // - In a not-ok result core reads `error.message`, so the error must be
2449
- // Error-like, not a bare string.
2450
- //
2451
- // Peer resolution deliberately does not happen here: `sendText` resolves
2452
- // the peer itself, and doing it here would force the hook async again.
2453
- resolveTarget(ctx) {
2454
- try {
2455
- const raw = typeof ctx.to === "string" ? ctx.to.trim() : "";
2456
- actionLog.info("clawgram outbound resolveTarget", {
2457
- accountId: ctx.accountId,
2458
- rawTo: raw || null,
2459
- });
2460
- if (!raw) {
2461
- return { ok: false, error: new Error("clawgram: no delivery target — pass `to` or use a session with a bound chat") };
2462
- }
2463
- return { ok: true, to: (0, helpers_1.normalizeOutboundTarget)(raw) };
2464
- }
2465
- catch (err) {
2466
- return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
2467
- }
2468
- },
2469
- async sendText(ctx) {
2470
- // Never log `text`: outbound bodies are private correspondence and the
2471
- // channel log is a plain journald sink. Length is enough to tell an
2472
- // empty or truncated send apart from a real one.
2473
- actionLog.info("clawgram outbound sendText", {
2474
- accountId: ctx.accountId,
2475
- rawTo: ctx.to,
2476
- replyToId: ctx.replyToId ?? null,
2477
- threadId: ctx.threadId ?? null,
2478
- textLength: ctx.text.length,
2479
- });
2480
- // Core normalizes reply payloads and drops the silent token before a
2481
- // channel is called, so this should never see one. "Should never" is
2482
- // what the inbound path was assumed to be too, right until it posted a
2483
- // token — and the check costs a string comparison.
2484
- if (ctx.text.trim() && (0, helpers_1.isSilentReplyText)(ctx.text)) {
2485
- actionLog.info("clawgram suppressing silent outbound send", {
2486
- accountId: ctx.accountId,
2487
- rawTo: ctx.to,
2488
- });
2489
- return { skipped: "silent" };
2490
- }
2491
- // Core's operational chatter (tool-error warnings, fallback notices)
2492
- // stays out of group chats: it is telemetry for the operator, not a
2493
- // reply to the room, and it has already been seen carrying shell
2494
- // commands with secret-store paths. DMs keep it. The text itself is
2495
- // never logged — see system-notice.ts for why.
2496
- const suppressedNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
2497
- targetKind: (0, helpers_1.inferOutboundTargetKind)(ctx.to),
2498
- text: ctx.text,
2499
- });
2500
- if (suppressedNotice) {
2501
- actionLog.warn("clawgram suppressing system notice in group", {
2502
- accountId: ctx.accountId,
2503
- rawTo: ctx.to,
2504
- noticeKind: suppressedNotice,
2505
- textLength: ctx.text.length,
2506
- });
2507
- return { skipped: "system-notice" };
2508
- }
2509
- const gram = runtimes.get(ctx.accountId);
2510
- if (!gram) {
2511
- throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
2512
- }
2513
- // The agent already answered this message with its own `send`, and this
2514
- // is core delivering the same turn's final text. Two messages for one
2515
- // answer is how 2026-08-10 read in a work chat: every request reported
2516
- // twice, in slightly different words, seconds apart.
2517
- //
2518
- // Core's own convention is that an agent which has sent a message
2519
- // returns NO_REPLY; this catches the turns that forget. The window is
2520
- // seconds wide, so a result the assistant comes back with later is
2521
- // still delivered.
2522
- if (ctx.replyToId !== null && ctx.replyToId !== undefined && (0, group_visible_reply_guard_1.hadTurnSendJustNow)({
2523
- accountId: ctx.accountId,
2524
- chatId: (0, helpers_1.normalizeOutboundTarget)(ctx.to),
2525
- currentMessageId: ctx.replyToId,
2526
- })) {
2527
- actionLog.warn("clawgram suppressing echo of a turn that already sent", {
2528
- accountId: ctx.accountId,
2529
- rawTo: ctx.to,
2530
- replyToId: ctx.replyToId,
2531
- textLength: ctx.text.length,
2532
- });
2533
- return { skipped: "duplicate" };
2534
- }
2535
- const groupReplyAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
2536
- accountId: ctx.accountId,
2537
- chatId: ctx.to,
2538
- replyToId: ctx.replyToId,
2539
- });
2540
- const targetKind = (0, helpers_1.inferOutboundTargetKind)(ctx.to);
2541
- const target = (0, helpers_1.normalizeOutboundTarget)(ctx.to);
2542
- const messageThreadId = parseOptionalThreadId(ctx.threadId);
2543
- const sent = await gram.sendText({
2544
- target,
2545
- text: (0, helpers_1.prefixReplyTextToAddress)(ctx.text, groupReplyAddress),
2546
- targetKind,
2547
- replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(ctx.to, ctx.replyToId),
2548
- messageThreadId,
2549
- parseMode: gram.replyParseMode,
2550
- });
2551
- actionLog.info("clawgram outbound sendText completed", {
2552
- accountId: ctx.accountId,
2553
- to: target,
2554
- targetKind,
2555
- replyToId: ctx.replyToId ?? null,
2556
- sentMessageId: String(sent?.id ?? ""),
2557
- });
2558
- return {
2559
- ok: true,
2560
- messageId: String(sent?.id ?? ""),
2561
- };
2562
- },
2563
- async sendMedia(ctx) {
2564
- const gram = runtimes.get(ctx.accountId);
2565
- if (!gram) {
2566
- throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
2567
- }
2568
- // Same rule as the action path: a local file outside the declared
2569
- // roots is refused before anything is uploaded.
2570
- const outboundRoots = ctx.mediaLocalRoots ?? ctx.mediaAccess?.localRoots;
2571
- (0, media_1.assertLocalMediaWithinRoots)(ctx.filePath, outboundRoots);
2572
- (0, media_1.assertLocalMediaWithinRoots)(ctx.mediaUrl, outboundRoots);
2573
- actionLog.info("clawgram outbound sendMedia", {
2574
- accountId: ctx.accountId,
2575
- rawTo: ctx.to,
2576
- replyToId: ctx.replyToId ?? null,
2577
- threadId: ctx.threadId ?? null,
2578
- filePath: ctx.filePath ?? null,
2579
- mediaUrl: ctx.mediaUrl ?? null,
2580
- hasText: Boolean(ctx.text),
2581
- hasCaption: Boolean(ctx.caption),
2582
- asVoice: ctx.audioAsVoice === true,
2583
- });
2584
- const file = ctx.filePath ?? ctx.mediaUrl;
2585
- if (!file) {
2586
- throw new Error("clawgram: sendMedia requires filePath or mediaUrl");
2587
- }
2588
- const messageThreadId = parseOptionalThreadId(ctx.threadId);
2589
- // Same normalization `sendText` does two functions up. Without it the
2590
- // channel prefix reaches peer resolution and the send throws — which is
2591
- // exactly how a synthesized group reply died on 2026-08-08, silently
2592
- // enough that the transcript fallback posted it as raw text instead.
2593
- const target = (0, helpers_1.normalizeOutboundTarget)(ctx.to);
2594
- const sent = await gram.sendMedia({
2595
- target,
2596
- file,
2597
- caption: ctx.caption ?? ctx.text,
2598
- // Captions follow the account reply format like every other reply:
2599
- // they are the same agent prose, just attached to a file (2.15.0).
2600
- parseMode: gram.replyParseMode,
2601
- replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(ctx.to, ctx.replyToId),
2602
- messageThreadId,
2603
- asVoice: ctx.audioAsVoice === true,
2604
- });
2605
- actionLog.info("clawgram outbound sendMedia completed", {
2606
- accountId: ctx.accountId,
2607
- to: ctx.to,
2608
- replyToId: ctx.replyToId ?? null,
2609
- sentMessageId: String(sent?.id ?? ""),
2610
- });
2611
- return {
2612
- ok: true,
2613
- messageId: String(sent?.id ?? ""),
2614
- };
2615
- },
2616
- },
2322
+ outbound: (0, outbound_1.createOutbound)(runtimes),
2617
2323
  };
2618
2324
  };
2619
2325
  exports.createChannelPlugin = createChannelPlugin;