clawgram 2.22.0 → 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,16 +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;
7
- exports.canonicalAction = canonicalAction;
6
+ exports.createChannelPlugin = exports.canonicalAction = exports.CORE_ACTION_SYNONYMS = void 0;
8
7
  const core_1 = require("openclaw/plugin-sdk/core");
9
8
  const node_os_1 = __importDefault(require("node:os"));
10
9
  const node_path_1 = __importDefault(require("node:path"));
11
- const node_fs_1 = require("node:fs");
12
- /** Attachments above this are left unread: a long recording or a huge image is
13
- * a different conversation from a spoken line or a screenshot, and the
14
- * transfer is not free. */
15
- const INBOUND_MEDIA_MAX_BYTES = 25 * 1024 * 1024;
16
10
  /**
17
11
  * How long a file fetched by `fetch-media` stays on disk.
18
12
  *
@@ -244,134 +238,10 @@ function readAccountManageChats(account) {
244
238
  const entries = Array.isArray(raw) ? raw : [raw];
245
239
  return entries.map((entry) => String(entry).trim()).filter(Boolean);
246
240
  }
247
- /**
248
- * Every accepted spelling of an action, mapped to its canonical name.
249
- *
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).
256
- *
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.
260
- */
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",
282
- "thread-list": "topics",
283
- dialogs: "dialogs",
284
- chats: "dialogs",
285
- "channel-list": "dialogs",
286
- chatInfo: "chatInfo",
287
- getChatInfo: "chatInfo",
288
- "channel-info": "chatInfo",
289
- chatMetadata: "chatInfo",
290
- getChatMetadata: "chatInfo",
291
- // Chat management. `kick` was already accepted; the rest were advertised
292
- // under names core does not know and were therefore never callable from the
293
- // tool at all — 2.19.4 gives them core's nearest name. `transferOwnership`
294
- // and `inviteLink` have no counterpart in that vocabulary and stay
295
- // gateway-only, as does `joins`.
296
- createGroup: "createGroup",
297
- createChat: "createGroup",
298
- "create-group": "createGroup",
299
- "channel-create": "createGroup",
300
- addMembers: "addMembers",
301
- addMember: "addMembers",
302
- "add-members": "addMembers",
303
- addParticipant: "addMembers",
304
- removeMember: "removeMember",
305
- removeMembers: "removeMember",
306
- "remove-member": "removeMember",
307
- kick: "removeMember",
308
- promoteAdmin: "promoteAdmin",
309
- promote: "promoteAdmin",
310
- "promote-admin": "promoteAdmin",
311
- setAdmin: "promoteAdmin",
312
- "role-add": "promoteAdmin",
313
- demoteAdmin: "demoteAdmin",
314
- demote: "demoteAdmin",
315
- "demote-admin": "demoteAdmin",
316
- "role-remove": "demoteAdmin",
317
- transferOwnership: "transferOwnership",
318
- transferOwner: "transferOwnership",
319
- "transfer-ownership": "transferOwnership",
320
- inviteLink: "inviteLink",
321
- exportInviteLink: "inviteLink",
322
- "invite-link": "inviteLink",
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
- ]);
361
- function parseOptionalThreadId(value) {
362
- if (typeof value === "number") {
363
- return Number.isFinite(value) ? Math.trunc(value) : undefined;
364
- }
365
- if (typeof value !== "string") {
366
- return undefined;
367
- }
368
- const trimmed = value.trim();
369
- if (!trimmed || !/^\d+$/.test(trimmed)) {
370
- return undefined;
371
- }
372
- const parsed = Number.parseInt(trimmed, 10);
373
- return Number.isFinite(parsed) ? parsed : undefined;
374
- }
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");
375
245
  /**
376
246
  * Turns an inbound attachment into text the agent can read.
377
247
  *
@@ -385,126 +255,7 @@ function parseOptionalThreadId(value) {
385
255
  * "you sent something I could not read" than staying silent, which is
386
256
  * indistinguishable from being offline.
387
257
  */
388
- /**
389
- * Locates the agent directory that image understanding needs.
390
- *
391
- * Image models are called with the agent's own credentials, so the pipeline
392
- * refuses to run without this path — audio does not need it, which is why
393
- * voice notes worked before images did. The platform exposes no resolver to
394
- * plugins, so the documented layout is reconstructed here and checked before
395
- * use: a wrong guess would fail the read anyway, and returning undefined lets
396
- * the caller degrade instead of throwing.
397
- */
398
- function resolveAgentDirForMedia(cfg) {
399
- const stateDir = (0, state_dir_1.resolveStateDir)();
400
- const configuredId = cfg?.agents?.defaults?.id;
401
- const agentId = typeof configuredId === "string" && configuredId.trim() ? configuredId.trim() : "main";
402
- const dir = node_path_1.default.join(stateDir, "agents", agentId, "agent");
403
- return (0, node_fs_1.existsSync)(dir) ? dir : undefined;
404
- }
405
- /**
406
- * Turns a downloaded attachment into text.
407
- *
408
- * Shared by the inbound path and by `fetch-media`: the backend choice lives in
409
- * `runtime.mediaUnderstanding`, and both callers have to make exactly the same
410
- * call — an image read on arrival and the same image read on request must not
411
- * become two different readings because two call sites drifted.
412
- */
413
- async function understandAttachmentFile(params) {
414
- const media = params.runtime?.mediaUnderstanding;
415
- if (!media)
416
- return undefined;
417
- const result = params.understanding === "transcript"
418
- ? await media.transcribeAudioFile({
419
- filePath: params.filePath,
420
- cfg: params.cfg,
421
- mime: params.mimeType,
422
- })
423
- : await media.describeImageFile({
424
- filePath: params.filePath,
425
- cfg: params.cfg,
426
- mime: params.mimeType,
427
- agentDir: resolveAgentDirForMedia(params.cfg),
428
- });
429
- const text = typeof result?.text === "string" ? result.text.trim() : "";
430
- return text || undefined;
431
- }
432
- async function readInboundAttachment(params) {
433
- const media = params.runtime?.mediaUnderstanding;
434
- const message = params.event?.message;
435
- if (!media || !message) {
436
- return undefined;
437
- }
438
- let downloaded;
439
- try {
440
- downloaded = await (0, media_1.downloadInboundMediaToTempFile)({
441
- client: params.gram.getClient(),
442
- message,
443
- maxBytes: INBOUND_MEDIA_MAX_BYTES,
444
- tmpDir: node_os_1.default.tmpdir(),
445
- });
446
- }
447
- catch (err) {
448
- params.log?.info?.("clawgram attachment download failed", {
449
- accountId: params.accountId,
450
- chatId: params.chatId,
451
- messageId: params.messageId,
452
- error: String(err),
453
- });
454
- return undefined;
455
- }
456
- if (!downloaded) {
457
- return undefined;
458
- }
459
- try {
460
- const read = await understandAttachmentFile({
461
- runtime: params.runtime,
462
- cfg: params.cfg,
463
- filePath: downloaded.path,
464
- mimeType: downloaded.mimeType,
465
- understanding: downloaded.understanding,
466
- });
467
- if (!read) {
468
- params.log?.info?.("clawgram attachment read empty", {
469
- accountId: params.accountId,
470
- chatId: params.chatId,
471
- messageId: params.messageId,
472
- understanding: downloaded.understanding,
473
- });
474
- return undefined;
475
- }
476
- params.log?.info?.("clawgram attachment read", {
477
- accountId: params.accountId,
478
- chatId: params.chatId,
479
- messageId: params.messageId,
480
- understanding: downloaded.understanding,
481
- characters: read.length,
482
- });
483
- return { text: read, understanding: downloaded.understanding };
484
- }
485
- catch (err) {
486
- params.log?.info?.("clawgram attachment read failed", {
487
- accountId: params.accountId,
488
- chatId: params.chatId,
489
- messageId: params.messageId,
490
- understanding: downloaded.understanding,
491
- error: String(err),
492
- });
493
- return undefined;
494
- }
495
- finally {
496
- void (async () => {
497
- try {
498
- const { rm } = await import("node:fs/promises");
499
- const { dirname } = await import("node:path");
500
- await rm(dirname(downloaded.path), { recursive: true, force: true });
501
- }
502
- catch {
503
- // Leaving a temp file behind is not worth failing a delivered message.
504
- }
505
- })();
506
- }
507
- }
258
+ const attachments_1 = require("./attachments");
508
259
  const createChannelPlugin = (runtimes, pluginRuntime) => {
509
260
  const resolveRuntimeAccountId = (cfg, preferred) => {
510
261
  const configured = (0, helpers_1.resolveConfiguredAccountId)(cfg, preferred);
@@ -516,6 +267,20 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
516
267
  }
517
268
  return configured ?? runtimes.keys().next().value;
518
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
+ };
519
284
  return {
520
285
  id: "clawgram",
521
286
  meta: {
@@ -801,7 +566,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
801
566
  // voice note and a screenshot alike, the attachment *is* the
802
567
  // message. A caption is kept and the reading appended, because
803
568
  // "look at this" plus the picture is one thought, not two.
804
- const attachment = senderMayReachAgent ? await readInboundAttachment({
569
+ const attachment = senderMayReachAgent ? await (0, attachments_1.readInboundAttachment)({
805
570
  gram,
806
571
  event,
807
572
  cfg,
@@ -1070,7 +835,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1070
835
  replyToId: normalized.messageId,
1071
836
  address: groupReplyAddress,
1072
837
  });
1073
- const messageThreadId = parseOptionalThreadId(normalized.messageThreadId);
838
+ const messageThreadId = (0, helpers_1.parseOptionalThreadId)(normalized.messageThreadId);
1074
839
  const groupTypingTarget = normalized.chatId;
1075
840
  await gram.withTyping(groupTypingTarget, async () => {
1076
841
  log?.info?.("clawgram dispatching group reply", {
@@ -1722,7 +1487,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1722
1487
  // resolved once, here, and `ACTION_ALIASES` is the only place that
1723
1488
  // decides what a name means. An unknown name stays itself and falls
1724
1489
  // through to the unsupported-action error, as before.
1725
- const canonical = canonicalAction(action);
1490
+ const canonical = (0, actions_1.canonicalAction)(action);
1726
1491
  // `read` is what OpenClaw core dispatches (`openclaw message read`,
1727
1492
  // MCP `messages_read`); `list` resolves to it too.
1728
1493
  if (canonical === "read") {
@@ -1837,7 +1602,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1837
1602
  const downloaded = await (0, media_1.downloadMessageMediaToFile)({
1838
1603
  client: fetchGram.getClient(),
1839
1604
  message: found.message,
1840
- maxBytes: INBOUND_MEDIA_MAX_BYTES,
1605
+ maxBytes: attachments_1.INBOUND_MEDIA_MAX_BYTES,
1841
1606
  dir: fetchDir,
1842
1607
  fileNameFor: ({ media, extension }) => (0, fetch_media_1.fetchedMediaFileName)({
1843
1608
  chatId: fetchChatId,
@@ -1853,7 +1618,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1853
1618
  // large to be worth the transfer. Saying "could not fetch" to all
1854
1619
  // three is how "she ignored the picture" starts.
1855
1620
  const described = (0, media_1.describeMedia)(found.message?.media);
1856
- 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;
1857
1622
  const error = !described
1858
1623
  ? "no-media"
1859
1624
  : tooLarge
@@ -1879,7 +1644,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1879
1644
  let readError;
1880
1645
  if (fetchParams.mode !== "file") {
1881
1646
  try {
1882
- read = await understandAttachmentFile({
1647
+ read = await (0, attachments_1.understandAttachmentFile)({
1883
1648
  runtime: pluginRuntime,
1884
1649
  cfg,
1885
1650
  filePath: downloaded.path,
@@ -1933,43 +1698,80 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1933
1698
  readError,
1934
1699
  });
1935
1700
  }
1936
- // Membership is a read, so the same `readChats` scope that gates history
1937
- // gates it too: this cannot become a way to enumerate chats the account
1938
- // was never allowed to read.
1939
- if (canonical === "participants") {
1940
- const participantsParams = (0, history_1.parseListParticipantsParams)(params);
1941
- const participantsAccountId = resolveRuntimeAccountId(cfg, accountId);
1942
- 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) {
1943
1724
  throw new Error("clawgram: no configured account found");
1944
1725
  }
1945
- if (!(0, history_1.isChatReadable)(participantsParams.target, resolveAccountReadChats(cfg, participantsAccountId))) {
1946
- actionLog.warn("clawgram participants refused: chat outside read scope", {
1947
- accountId: participantsAccountId,
1948
- target: participantsParams.target,
1949
- });
1950
- 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
+ }
1951
1735
  }
1952
- const participantsGram = runtimes.get(participantsAccountId);
1953
- if (!participantsGram) {
1954
- 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
+ }
1955
1743
  }
1956
- const membership = await participantsGram.listParticipants(participantsParams);
1957
- // Counts only. Member ids are personal data and have no business in a
1958
- // log that is read while debugging something else.
1959
- actionLog.info("clawgram handleAction participants completed", {
1960
- accountId: participantsAccountId,
1961
- target: participantsParams.target,
1962
- limit: participantsParams.limit,
1963
- returned: membership.participants.length,
1964
- 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),
1965
1749
  });
1966
- return (0, core_1.jsonResult)({
1967
- ok: true,
1968
- accountId: participantsAccountId,
1969
- chatId: membership.chatId ?? participantsParams.target,
1970
- count: membership.participants.length,
1971
- truncated: membership.truncated,
1972
- 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
+ }),
1973
1775
  });
1974
1776
  }
1975
1777
  // Topic names. A forum chat is addressed by topic id, and until now an
@@ -1977,133 +1779,77 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1977
1779
  // written in yet was unreachable, and one named in words was unfindable.
1978
1780
  // Titles say what a chat is working on, so the read scope gates them.
1979
1781
  if (canonical === "topics") {
1980
- const topicsParams = (0, topics_1.parseTopicsParams)(params);
1981
- const topicsAccountId = resolveRuntimeAccountId(cfg, accountId);
1982
- if (!topicsAccountId) {
1983
- throw new Error("clawgram: no configured account found");
1984
- }
1985
- if (!(0, history_1.isChatReadable)(topicsParams.target, resolveAccountReadChats(cfg, topicsAccountId))) {
1986
- actionLog.warn("clawgram topics refused: chat outside read scope", {
1987
- accountId: topicsAccountId,
1988
- target: topicsParams.target,
1989
- });
1990
- throw new Error(`clawgram: not-allowed-chat ${topicsParams.target}`);
1991
- }
1992
- const topicsGram = runtimes.get(topicsAccountId);
1993
- if (!topicsGram) {
1994
- throw new Error(`clawgram: runtime not found for account ${topicsAccountId}`);
1995
- }
1996
- const forum = await topicsGram.listTopics(topicsParams);
1997
- actionLog.info("clawgram handleAction topics completed", {
1998
- accountId: topicsAccountId,
1999
- target: topicsParams.target,
2000
- limit: topicsParams.limit,
2001
- returned: forum.topics.length,
2002
- truncated: forum.truncated,
2003
- });
2004
- return (0, core_1.jsonResult)({
2005
- ok: true,
2006
- accountId: topicsAccountId,
2007
- chatId: forum.chatId ?? topicsParams.target,
2008
- count: forum.topics.length,
2009
- truncated: forum.truncated,
2010
- topics: forum.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
+ }),
2011
1799
  });
2012
1800
  }
2013
1801
  // Which chats this account is in. Not gated by `readChats` — the whole
2014
1802
  // point is to find chats that are not in it yet — so it has a gate of
2015
1803
  // its own, is metadata only, and never reports direct chats.
2016
1804
  if (canonical === "dialogs") {
2017
- const dialogsParams = (0, dialogs_1.parseDialogsParams)(params);
2018
- const dialogsAccountId = resolveRuntimeAccountId(cfg, accountId);
2019
- if (!dialogsAccountId) {
2020
- throw new Error("clawgram: no configured account found");
2021
- }
2022
- if (!(0, dialogs_1.isChatDiscoveryEnabled)(resolveAccountDiscoverChats(cfg, dialogsAccountId))) {
2023
- actionLog.warn("clawgram dialogs refused: chat-discovery is not enabled", {
2024
- accountId: dialogsAccountId,
2025
- });
2026
- throw new Error("clawgram: chat-discovery is not enabled");
2027
- }
2028
- const dialogsGram = runtimes.get(dialogsAccountId);
2029
- if (!dialogsGram) {
2030
- throw new Error(`clawgram: runtime not found for account ${dialogsAccountId}`);
2031
- }
2032
- const found = await dialogsGram.listDialogs(dialogsParams);
2033
- // Counts only: which chats a person's account sits in is exactly the
2034
- // kind of thing that should not be sitting in a log.
2035
- actionLog.info("clawgram handleAction dialogs completed", {
2036
- accountId: dialogsAccountId,
2037
- limit: dialogsParams.limit,
2038
- returned: found.dialogs.length,
2039
- truncated: found.truncated,
2040
- });
2041
- return (0, core_1.jsonResult)({
2042
- ok: true,
2043
- accountId: dialogsAccountId,
2044
- count: found.dialogs.length,
2045
- truncated: found.truncated,
2046
- dialogs: found.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 }),
2047
1814
  });
2048
1815
  }
2049
1816
  // Where this account was recently added, and by whom. Reading the journal
2050
1817
  // has no scope check of its own: it only ever contains chats this account
2051
1818
  // was put into, which is exactly what the caller is allowed to learn.
2052
1819
  if (canonical === "joins") {
2053
- const joinsParams = (0, joins_1.parseJoinsParams)(params);
2054
- const joinsAccountId = resolveRuntimeAccountId(cfg, accountId);
2055
- if (!joinsAccountId) {
2056
- throw new Error("clawgram: no configured account found");
2057
- }
2058
- const journalPath = (0, joins_1.resolveJoinsJournalPath)(cfg?.channels?.["clawgram"]?.accounts?.[joinsAccountId], joinsAccountId);
2059
- const selected = (0, joins_1.selectJoinRecords)((0, joins_1.readJoinRecords)(journalPath), joinsParams);
2060
- actionLog.info("clawgram handleAction joins completed", {
2061
- accountId: joinsAccountId,
2062
- since: joinsParams.since ?? null,
2063
- limit: joinsParams.limit,
2064
- returned: selected.length,
2065
- });
2066
- return (0, core_1.jsonResult)({
2067
- ok: true,
2068
- accountId: joinsAccountId,
2069
- count: selected.length,
2070
- joins: selected,
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 }),
2071
1831
  });
2072
1832
  }
2073
1833
  // Describing a chat is a read, so the same `readChats` scope that gates
2074
1834
  // history gates it too — this must not become a way to learn the title
2075
1835
  // and size of a chat the account was never allowed to read.
2076
1836
  if (canonical === "chatInfo") {
2077
- const chatInfoParams = (0, chat_info_1.parseChatInfoParams)(params, toolContext);
2078
- const chatInfoAccountId = resolveRuntimeAccountId(cfg, accountId);
2079
- if (!chatInfoAccountId) {
2080
- throw new Error("clawgram: no configured account found");
2081
- }
2082
- if (!(0, history_1.isChatReadable)(chatInfoParams.target, resolveAccountReadChats(cfg, chatInfoAccountId))) {
2083
- actionLog.warn("clawgram chatInfo refused: chat outside read scope", {
2084
- accountId: chatInfoAccountId,
2085
- target: chatInfoParams.target,
2086
- });
2087
- throw new Error(`clawgram: not-allowed-chat ${chatInfoParams.target}`);
2088
- }
2089
- const chatInfoGram = runtimes.get(chatInfoAccountId);
2090
- if (!chatInfoGram) {
2091
- throw new Error(`clawgram: runtime not found for account ${chatInfoAccountId}`);
2092
- }
2093
- const { entity, full } = await chatInfoGram.getChatInfo(chatInfoParams.target);
2094
- const info = (0, chat_info_1.describeChat)(entity, full);
2095
- // Type and size only. The title of a private chat is as personal as
2096
- // its contents and has no business in a debugging log.
2097
- actionLog.info("clawgram handleAction chatInfo completed", {
2098
- accountId: chatInfoAccountId,
2099
- type: info.type,
2100
- memberCount: info.memberCount ?? null,
2101
- isForum: info.isForum ?? null,
2102
- });
2103
- return (0, core_1.jsonResult)({
2104
- ok: true,
2105
- accountId: chatInfoAccountId,
2106
- chat: { ...info, chatId: info.chatId ?? chatInfoParams.target },
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 } }),
2107
1853
  });
2108
1854
  }
2109
1855
  // A reaction is an outbound act on someone else's message, so it is
@@ -2164,15 +1910,43 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2164
1910
  // returns after the gate so a dry run exercises the same refusals a
2165
1911
  // real call would hit. People's ids stay out of the logs throughout;
2166
1912
  // the JSON result carries them to the caller, the journal does not.
2167
- const manageAction = MANAGE_ACTIONS.has(canonical) ? canonical : undefined;
1913
+ const manageAction = actions_1.MANAGE_ACTIONS.has(canonical) ? canonical : undefined;
2168
1914
  if (manageAction) {
2169
1915
  const manageAccountId = resolveRuntimeAccountId(cfg, accountId);
2170
1916
  if (!manageAccountId) {
2171
1917
  throw new Error("clawgram: no configured account found");
2172
1918
  }
2173
1919
  const manageScope = resolveAccountManageChats(cfg, manageAccountId);
2174
- const requireManagedChat = (target) => {
2175
- 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)) {
2176
1950
  actionLog.warn("clawgram management refused: chat outside manage scope", {
2177
1951
  accountId: manageAccountId,
2178
1952
  action: manageAction,
@@ -2180,188 +1954,126 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2180
1954
  });
2181
1955
  throw new Error(`clawgram: not-managed-chat ${target}`);
2182
1956
  }
2183
- };
2184
- const requireRuntime = () => {
2185
- const gram = runtimes.get(manageAccountId);
2186
- if (!gram) {
2187
- throw new Error(`clawgram: runtime not found for account ${manageAccountId}`);
2188
- }
2189
- return gram;
2190
- };
2191
- if (manageAction === "createGroup") {
2192
- const createParams = (0, manage_1.parseCreateGroupParams)(params);
2193
- // A group being created is not in any scope yet, so the gate is
2194
- // coarser: management must be enabled at all for this account.
2195
- if (!(0, manage_1.isManagementEnabled)(manageScope)) {
2196
- actionLog.warn("clawgram createGroup refused: management is not enabled", {
2197
- accountId: manageAccountId,
2198
- });
2199
- throw new Error("clawgram: chat management is not enabled for this account — "
2200
- + `set channels.clawgram.accounts.${manageAccountId}.manageChats`);
2201
- }
2202
- actionLog.info("clawgram handleAction createGroup", {
1957
+ actionLog.info(`clawgram handleAction ${spec.name}`, {
2203
1958
  accountId: manageAccountId,
2204
1959
  dryRun: dryRun === true,
2205
- users: createParams.users.length,
2206
- hasAbout: Boolean(createParams.about),
1960
+ ...spec.before(parsed),
2207
1961
  });
2208
1962
  if (dryRun === true) {
2209
- 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
+ });
2210
1969
  }
2211
- const created = await requireRuntime().createGroup(createParams);
2212
- 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`, {
2213
1974
  accountId: manageAccountId,
2214
- chatId: created.chatId ?? null,
2215
- missing: created.missing.length,
1975
+ ...spec.after(parsed, result),
2216
1976
  });
2217
- return (0, core_1.jsonResult)({
2218
- ok: true,
2219
- accountId: manageAccountId,
2220
- chatId: created.chatId,
2221
- 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 }),
2222
1989
  });
2223
1990
  }
2224
1991
  if (manageAction === "addMembers") {
2225
- const addParams = (0, manage_1.parseAddMembersParams)(params, toolContext);
2226
- requireManagedChat(addParams.target);
2227
- actionLog.info("clawgram handleAction addMembers", {
2228
- accountId: manageAccountId,
2229
- dryRun: dryRun === true,
2230
- target: addParams.target,
2231
- users: addParams.users.length,
2232
- });
2233
- if (dryRun === true) {
2234
- return (0, core_1.jsonResult)({ ok: true, dryRun: true, accountId: manageAccountId, chatId: addParams.target });
2235
- }
2236
- const added = await requireRuntime().addChatMembers(addParams);
2237
- actionLog.info("clawgram handleAction addMembers completed", {
2238
- accountId: manageAccountId,
2239
- target: addParams.target,
2240
- requested: addParams.users.length,
2241
- missing: added.missing.length,
2242
- });
2243
- return (0, core_1.jsonResult)({
2244
- ok: true,
2245
- accountId: manageAccountId,
2246
- chatId: added.chatId ?? addParams.target,
2247
- requested: addParams.users.length,
2248
- // Telegram refuses silently-restricted invites per user; the
2249
- // caller gets the ids so it can hand them an invite link.
2250
- 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
+ }),
2251
2010
  });
2252
2011
  }
2253
2012
  if (manageAction === "removeMember") {
2254
- const removeParams = (0, manage_1.parseRemoveMemberParams)(params, toolContext);
2255
- requireManagedChat(removeParams.target);
2256
- actionLog.info("clawgram handleAction removeMember", {
2257
- accountId: manageAccountId,
2258
- dryRun: dryRun === true,
2259
- target: removeParams.target,
2260
- ban: removeParams.ban,
2261
- });
2262
- if (dryRun === true) {
2263
- return (0, core_1.jsonResult)({ ok: true, dryRun: true, accountId: manageAccountId, chatId: removeParams.target });
2264
- }
2265
- await requireRuntime().removeChatMember(removeParams);
2266
- actionLog.info("clawgram handleAction removeMember completed", {
2267
- accountId: manageAccountId,
2268
- target: removeParams.target,
2269
- ban: removeParams.ban,
2270
- });
2271
- return (0, core_1.jsonResult)({
2272
- ok: true,
2273
- accountId: manageAccountId,
2274
- chatId: removeParams.target,
2275
- user: removeParams.user,
2276
- 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 }),
2277
2021
  });
2278
2022
  }
2279
2023
  if (manageAction === "promoteAdmin" || manageAction === "demoteAdmin") {
2280
- const adminParams = manageAction === "promoteAdmin"
2281
- ? (0, manage_1.parsePromoteAdminParams)(params, toolContext)
2282
- : (0, manage_1.parseDemoteAdminParams)(params, toolContext);
2283
- requireManagedChat(adminParams.target);
2284
- actionLog.info("clawgram handleAction setAdmin", {
2285
- accountId: manageAccountId,
2286
- dryRun: dryRun === true,
2287
- target: adminParams.target,
2288
- isAdmin: adminParams.isAdmin,
2289
- hasRank: Boolean(adminParams.rank),
2290
- });
2291
- if (dryRun === true) {
2292
- return (0, core_1.jsonResult)({ ok: true, dryRun: true, accountId: manageAccountId, chatId: adminParams.target });
2293
- }
2294
- await requireRuntime().setChatAdmin(adminParams);
2295
- actionLog.info("clawgram handleAction setAdmin completed", {
2296
- accountId: manageAccountId,
2297
- target: adminParams.target,
2298
- isAdmin: adminParams.isAdmin,
2299
- });
2300
- return (0, core_1.jsonResult)({
2301
- ok: true,
2302
- accountId: manageAccountId,
2303
- chatId: adminParams.target,
2304
- user: adminParams.user,
2305
- isAdmin: adminParams.isAdmin,
2306
- ...(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
+ }),
2307
2041
  });
2308
2042
  }
2309
2043
  if (manageAction === "transferOwnership") {
2310
- const transferParams = (0, manage_1.parseTransferOwnershipParams)(params, toolContext);
2311
- requireManagedChat(transferParams.target);
2312
- actionLog.info("clawgram handleAction transferOwnership", {
2313
- accountId: manageAccountId,
2314
- dryRun: dryRun === true,
2315
- target: transferParams.target,
2316
- });
2317
- if (dryRun === true) {
2318
- return (0, core_1.jsonResult)({ ok: true, dryRun: true, accountId: manageAccountId, chatId: transferParams.target });
2319
- }
2320
- const transferGram = requireRuntime();
2321
- // The password stays inside the runtime: it is read from the
2322
- // account config at start-up and never travels through dispatch
2323
- // arguments, which are one log call away from the journal.
2324
- if (!transferGram.twoFaPassword) {
2325
- throw new Error("clawgram: ownership transfer requires twoFaPassword in the account config "
2326
- + "(the account's Telegram 2FA password, as a literal or a SecretRef)");
2327
- }
2328
- await transferGram.transferChatOwnership(transferParams);
2329
- actionLog.info("clawgram handleAction transferOwnership completed", {
2330
- accountId: manageAccountId,
2331
- target: transferParams.target,
2332
- });
2333
- return (0, core_1.jsonResult)({
2334
- ok: true,
2335
- accountId: manageAccountId,
2336
- chatId: transferParams.target,
2337
- 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 }),
2338
2061
  });
2339
2062
  }
2340
2063
  // inviteLink — the only management action left.
2341
- const inviteParams = (0, manage_1.parseInviteLinkParams)(params, toolContext);
2342
- requireManagedChat(inviteParams.target);
2343
- actionLog.info("clawgram handleAction inviteLink", {
2344
- accountId: manageAccountId,
2345
- dryRun: dryRun === true,
2346
- target: inviteParams.target,
2347
- hasExpiry: inviteParams.expireDate !== undefined,
2348
- usageLimit: inviteParams.usageLimit ?? null,
2349
- requestNeeded: inviteParams.requestNeeded,
2350
- });
2351
- if (dryRun === true) {
2352
- return (0, core_1.jsonResult)({ ok: true, dryRun: true, accountId: manageAccountId, chatId: inviteParams.target });
2353
- }
2354
- const exported = await requireRuntime().exportChatInviteLink(inviteParams);
2355
- actionLog.info("clawgram handleAction inviteLink completed", {
2356
- accountId: manageAccountId,
2357
- target: inviteParams.target,
2358
- hasLink: Boolean(exported.link),
2359
- });
2360
- return (0, core_1.jsonResult)({
2361
- ok: true,
2362
- accountId: manageAccountId,
2363
- chatId: inviteParams.target,
2364
- 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 }),
2365
2077
  });
2366
2078
  }
2367
2079
  // Core normalizes whichever of these it filled in to a local path (see
@@ -2432,7 +2144,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2432
2144
  // the same prose as a message and renders identically.
2433
2145
  parseMode: (0, helpers_1.resolveOutboundParseMode)(params, cfg, uploadAccountId),
2434
2146
  replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(rawUploadTo, uploadReplyToId),
2435
- messageThreadId: parseOptionalThreadId(uploadThreadId),
2147
+ messageThreadId: (0, helpers_1.parseOptionalThreadId)(uploadThreadId),
2436
2148
  asVoice,
2437
2149
  });
2438
2150
  actionLog.info("clawgram handleAction upload-file completed", {
@@ -2455,7 +2167,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2455
2167
  const to = (0, helpers_1.normalizeOutboundTarget)(rawTo);
2456
2168
  const replyToId = (0, param_readers_1.readStringOrNumberParam)(params, "replyToId") ?? (0, param_readers_1.readStringOrNumberParam)(params, "replyTo");
2457
2169
  const threadId = (0, param_readers_1.readStringOrNumberParam)(params, "threadId");
2458
- const messageThreadId = parseOptionalThreadId(threadId);
2170
+ const messageThreadId = (0, helpers_1.parseOptionalThreadId)(threadId);
2459
2171
  // Omitting parseMode inherits the account's configured mode rather
2460
2172
  // than falling back to plain text (2.13.0): an account set to `html`
2461
2173
  // used to render replies as HTML and these sends as raw markup.
@@ -2607,245 +2319,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2607
2319
  });
2608
2320
  },
2609
2321
  },
2610
- outbound: {
2611
- // Core's agent-delivery path (`--deliver`, subagent announces) calls this
2612
- // hook under three constraints, all learned live on 2026-08-06:
2613
- //
2614
- // - `to` may be undefined (no explicit target, session route yielded
2615
- // none), and a rejection is NOT caught: a throw here is an unhandled
2616
- // rejection that takes down the entire gateway process.
2617
- // - `resolveAgentDeliveryPlanWithSessionRoute` calls it WITHOUT await.
2618
- // An async hook hands core a Promise, `promise.ok` reads undefined and
2619
- // the error branch dereferences `promise.error.message` — the crash
2620
- // every subagent announce died on. The hook must return a plain value;
2621
- // the call sites that do await are unaffected, await of a value works.
2622
- // - In a not-ok result core reads `error.message`, so the error must be
2623
- // Error-like, not a bare string.
2624
- //
2625
- // Peer resolution deliberately does not happen here: `sendText` resolves
2626
- // the peer itself, and doing it here would force the hook async again.
2627
- resolveTarget(ctx) {
2628
- try {
2629
- const raw = typeof ctx.to === "string" ? ctx.to.trim() : "";
2630
- actionLog.info("clawgram outbound resolveTarget", {
2631
- accountId: ctx.accountId,
2632
- rawTo: raw || null,
2633
- });
2634
- if (!raw) {
2635
- return { ok: false, error: new Error("clawgram: no delivery target — pass `to` or use a session with a bound chat") };
2636
- }
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 };
2654
- }
2655
- catch (err) {
2656
- return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
2657
- }
2658
- },
2659
- async sendText(ctx) {
2660
- // Never log `text`: outbound bodies are private correspondence and the
2661
- // channel log is a plain journald sink. Length is enough to tell an
2662
- // empty or truncated send apart from a real one.
2663
- actionLog.info("clawgram outbound sendText", {
2664
- accountId: ctx.accountId,
2665
- rawTo: ctx.to,
2666
- replyToId: ctx.replyToId ?? null,
2667
- threadId: ctx.threadId ?? null,
2668
- textLength: ctx.text.length,
2669
- });
2670
- // Core normalizes reply payloads and drops the silent token before a
2671
- // channel is called, so this should never see one. "Should never" is
2672
- // what the inbound path was assumed to be too, right until it posted a
2673
- // token — and the check costs a string comparison.
2674
- if (ctx.text.trim() && (0, helpers_1.isSilentReplyText)(ctx.text)) {
2675
- actionLog.info("clawgram suppressing silent outbound send", {
2676
- accountId: ctx.accountId,
2677
- rawTo: ctx.to,
2678
- });
2679
- return { skipped: "silent" };
2680
- }
2681
- // Core's operational chatter (tool-error warnings, fallback notices)
2682
- // stays out of group chats: it is telemetry for the operator, not a
2683
- // reply to the room, and it has already been seen carrying shell
2684
- // commands with secret-store paths. DMs keep it. The text itself is
2685
- // never logged — see system-notice.ts for why.
2686
- const suppressedNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
2687
- targetKind: (0, helpers_1.inferOutboundTargetKind)(ctx.to),
2688
- text: ctx.text,
2689
- to: ctx.to,
2690
- operatorIds: (0, system_notice_1.operatorIdsFor)(ctx.accountId),
2691
- });
2692
- if (suppressedNotice) {
2693
- actionLog.warn("clawgram suppressing system notice in group", {
2694
- accountId: ctx.accountId,
2695
- rawTo: ctx.to,
2696
- noticeKind: suppressedNotice,
2697
- textLength: ctx.text.length,
2698
- });
2699
- return { skipped: "system-notice" };
2700
- }
2701
- const gram = runtimes.get(ctx.accountId);
2702
- if (!gram) {
2703
- throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
2704
- }
2705
- // The agent already answered this message with its own `send`, and this
2706
- // is core delivering the same turn's final text. Two messages for one
2707
- // answer is how 2026-08-10 read in a work chat: every request reported
2708
- // twice, in slightly different words, seconds apart.
2709
- //
2710
- // Core's own convention is that an agent which has sent a message
2711
- // returns NO_REPLY; this catches the turns that forget. The window is
2712
- // seconds wide, so a result the assistant comes back with later is
2713
- // still delivered.
2714
- if (ctx.replyToId !== null && ctx.replyToId !== undefined && (0, group_visible_reply_guard_1.hadTurnSendJustNow)({
2715
- accountId: ctx.accountId,
2716
- chatId: (0, helpers_1.normalizeOutboundTarget)(ctx.to),
2717
- currentMessageId: ctx.replyToId,
2718
- })) {
2719
- actionLog.warn("clawgram suppressing echo of a turn that already sent", {
2720
- accountId: ctx.accountId,
2721
- rawTo: ctx.to,
2722
- replyToId: ctx.replyToId,
2723
- textLength: ctx.text.length,
2724
- });
2725
- return { skipped: "duplicate" };
2726
- }
2727
- const groupReplyAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
2728
- accountId: ctx.accountId,
2729
- chatId: ctx.to,
2730
- replyToId: ctx.replyToId,
2731
- });
2732
- const targetKind = (0, helpers_1.inferOutboundTargetKind)(ctx.to);
2733
- const target = (0, helpers_1.normalizeOutboundTarget)(ctx.to);
2734
- const messageThreadId = parseOptionalThreadId(ctx.threadId);
2735
- const sent = await gram.sendText({
2736
- target,
2737
- text: (0, helpers_1.prefixReplyTextToAddress)(ctx.text, groupReplyAddress),
2738
- targetKind,
2739
- replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(ctx.to, ctx.replyToId),
2740
- messageThreadId,
2741
- parseMode: gram.replyParseMode,
2742
- });
2743
- actionLog.info("clawgram outbound sendText completed", {
2744
- accountId: ctx.accountId,
2745
- to: target,
2746
- targetKind,
2747
- replyToId: ctx.replyToId ?? null,
2748
- sentMessageId: String(sent?.id ?? ""),
2749
- });
2750
- return {
2751
- ok: true,
2752
- messageId: String(sent?.id ?? ""),
2753
- };
2754
- },
2755
- async sendMedia(ctx) {
2756
- const gram = runtimes.get(ctx.accountId);
2757
- if (!gram) {
2758
- throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
2759
- }
2760
- // Same rule as the action path: a local file outside the declared
2761
- // roots is refused before anything is uploaded.
2762
- const outboundRoots = ctx.mediaLocalRoots ?? ctx.mediaAccess?.localRoots;
2763
- (0, media_1.assertLocalMediaWithinRoots)(ctx.filePath, outboundRoots);
2764
- (0, media_1.assertLocalMediaWithinRoots)(ctx.mediaUrl, outboundRoots);
2765
- actionLog.info("clawgram outbound sendMedia", {
2766
- accountId: ctx.accountId,
2767
- rawTo: ctx.to,
2768
- replyToId: ctx.replyToId ?? null,
2769
- threadId: ctx.threadId ?? null,
2770
- filePath: ctx.filePath ?? null,
2771
- mediaUrl: ctx.mediaUrl ?? null,
2772
- hasText: Boolean(ctx.text),
2773
- hasCaption: Boolean(ctx.caption),
2774
- asVoice: ctx.audioAsVoice === true,
2775
- });
2776
- const file = ctx.filePath ?? ctx.mediaUrl;
2777
- if (!file) {
2778
- throw new Error("clawgram: sendMedia requires filePath or mediaUrl");
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
- // медиа-доставка ими не бывает.
2817
- const messageThreadId = parseOptionalThreadId(ctx.threadId);
2818
- // Same normalization `sendText` does two functions up. Without it the
2819
- // channel prefix reaches peer resolution and the send throws — which is
2820
- // exactly how a synthesized group reply died on 2026-08-08, silently
2821
- // enough that the transcript fallback posted it as raw text instead.
2822
- const target = (0, helpers_1.normalizeOutboundTarget)(ctx.to);
2823
- const sent = await gram.sendMedia({
2824
- target,
2825
- file,
2826
- // Подпись получает то же обращение, что и текстовый ответ.
2827
- caption: mediaCaption
2828
- ? (0, helpers_1.prefixReplyTextToAddress)(mediaCaption, mediaReplyAddress)
2829
- : mediaCaption,
2830
- // Captions follow the account reply format like every other reply:
2831
- // they are the same agent prose, just attached to a file (2.15.0).
2832
- parseMode: gram.replyParseMode,
2833
- replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(ctx.to, ctx.replyToId),
2834
- messageThreadId,
2835
- asVoice: ctx.audioAsVoice === true,
2836
- });
2837
- actionLog.info("clawgram outbound sendMedia completed", {
2838
- accountId: ctx.accountId,
2839
- to: ctx.to,
2840
- replyToId: ctx.replyToId ?? null,
2841
- sentMessageId: String(sent?.id ?? ""),
2842
- });
2843
- return {
2844
- ok: true,
2845
- messageId: String(sent?.id ?? ""),
2846
- };
2847
- },
2848
- },
2322
+ outbound: (0, outbound_1.createOutbound)(runtimes),
2849
2323
  };
2850
2324
  };
2851
2325
  exports.createChannelPlugin = createChannelPlugin;