clawgram 2.22.0 → 2.24.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/actions.js +124 -0
- package/dist/attachments.js +142 -0
- package/dist/channel.js +286 -1677
- package/dist/helpers.js +47 -0
- package/dist/inbound-pipeline.js +872 -0
- package/dist/outbound.js +260 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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
|
*
|
|
@@ -60,22 +54,14 @@ const fetch_media_1 = require("./fetch-media");
|
|
|
60
54
|
const channel_runtime_1 = require("openclaw/plugin-sdk/channel-runtime");
|
|
61
55
|
const param_readers_1 = require("openclaw/plugin-sdk/param-readers");
|
|
62
56
|
const tool_send_1 = require("openclaw/plugin-sdk/tool-send");
|
|
63
|
-
const direct_dm_1 = require("openclaw/plugin-sdk/direct-dm");
|
|
64
|
-
const channel_inbound_1 = require("openclaw/plugin-sdk/channel-inbound");
|
|
65
|
-
const channel_reply_pipeline_1 = require("openclaw/plugin-sdk/channel-reply-pipeline");
|
|
66
|
-
const inbound_envelope_1 = require("openclaw/plugin-sdk/inbound-envelope");
|
|
67
|
-
const inbound_reply_dispatch_1 = require("openclaw/plugin-sdk/inbound-reply-dispatch");
|
|
68
57
|
const channel_pairing_1 = require("openclaw/plugin-sdk/channel-pairing");
|
|
69
58
|
const events_1 = require("telegram/events");
|
|
70
|
-
const constants_1 = require("./constants");
|
|
71
59
|
const gramjs_client_1 = require("./gramjs-client");
|
|
72
|
-
const normalize_1 = require("./normalize");
|
|
73
60
|
const history_1 = require("./history");
|
|
74
61
|
const send_scope_1 = require("./send-scope");
|
|
75
62
|
const joins_1 = require("./joins");
|
|
76
63
|
const reactions_1 = require("./reactions");
|
|
77
64
|
const manage_1 = require("./manage");
|
|
78
|
-
const silent_reaction_1 = require("./silent-reaction");
|
|
79
65
|
const system_notice_1 = require("./system-notice");
|
|
80
66
|
const state_dir_1 = require("./state-dir");
|
|
81
67
|
const chat_info_1 = require("./chat-info");
|
|
@@ -88,68 +74,8 @@ const group_reply_address_1 = require("./group-reply-address");
|
|
|
88
74
|
const group_visible_reply_guard_1 = require("./group-visible-reply-guard");
|
|
89
75
|
const helpers_1 = require("./helpers");
|
|
90
76
|
const proxy_config_1 = require("./proxy-config");
|
|
91
|
-
const
|
|
77
|
+
const constants_1 = require("./constants");
|
|
92
78
|
const actionLog = (0, core_1.createSubsystemLogger)("channels/clawgram");
|
|
93
|
-
/** Reads the configured reaction level for an account, tolerating a missing config. */
|
|
94
|
-
function readAccountReactionLevel(cfg, accountId) {
|
|
95
|
-
const resolvedAccountId = (0, helpers_1.resolveConfiguredAccountId)(cfg, accountId);
|
|
96
|
-
if (!resolvedAccountId) {
|
|
97
|
-
return undefined;
|
|
98
|
-
}
|
|
99
|
-
return cfg?.channels?.[constants_2.CHANNEL_ID]?.accounts?.[resolvedAccountId]?.reactionLevel;
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Model ref for the emoji pick, when the account names one.
|
|
103
|
-
*
|
|
104
|
-
* Picking one emoji out of a fixed list of 68 is the cheapest judgement this
|
|
105
|
-
* channel makes and the only model call it makes on its own; running it on the
|
|
106
|
-
* agent's own head spends the expensive quota on a decision a small model
|
|
107
|
-
* makes just as well.
|
|
108
|
-
*/
|
|
109
|
-
function readAccountReactionModel(cfg, accountId) {
|
|
110
|
-
const resolvedAccountId = (0, helpers_1.resolveConfiguredAccountId)(cfg, accountId);
|
|
111
|
-
if (!resolvedAccountId) {
|
|
112
|
-
return undefined;
|
|
113
|
-
}
|
|
114
|
-
const raw = cfg?.channels?.[constants_2.CHANNEL_ID]?.accounts?.[resolvedAccountId]?.reactionModel;
|
|
115
|
-
return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Wires `reactToSilentMention` to this account's runtime, config and log.
|
|
119
|
-
*
|
|
120
|
-
* The decision itself lives in `silent-reaction.ts`, testable without a
|
|
121
|
-
* Telegram connection; everything here is lookup. Missing pieces — no
|
|
122
|
-
* connected client, no model access — resolve to no reaction rather than to
|
|
123
|
-
* an error, because by this point the agent has already declined to reply.
|
|
124
|
-
*/
|
|
125
|
-
async function reactToSilentMentionForAccount(params) {
|
|
126
|
-
const gram = params.gram;
|
|
127
|
-
const llm = params.pluginRuntime?.llm;
|
|
128
|
-
if (!gram || typeof llm?.complete !== "function") {
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
await (0, silent_reaction_1.reactToSilentMention)({
|
|
132
|
-
appetite: (0, reactions_1.resolveAgentReactionGuidance)(readAccountReactionLevel(params.cfg, params.accountId)),
|
|
133
|
-
model: readAccountReactionModel(params.cfg, params.accountId),
|
|
134
|
-
wasMentioned: params.wasMentioned,
|
|
135
|
-
chatId: params.chatId,
|
|
136
|
-
messageId: params.messageId,
|
|
137
|
-
messageText: params.messageText,
|
|
138
|
-
deps: {
|
|
139
|
-
// Bound rather than destructured: the SDK may implement this as a
|
|
140
|
-
// method that needs its receiver.
|
|
141
|
-
complete: (args) => llm.complete(args),
|
|
142
|
-
sendReaction: (args) => gram.sendReaction(args),
|
|
143
|
-
allowedReactions: gram.getAllowedReactions
|
|
144
|
-
? () => gram.getAllowedReactions(params.chatId)
|
|
145
|
-
: undefined,
|
|
146
|
-
onDecision: (info) => actionLog.info("clawgram silent-mention reaction", {
|
|
147
|
-
accountId: params.accountId,
|
|
148
|
-
...info,
|
|
149
|
-
}),
|
|
150
|
-
},
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
79
|
/**
|
|
154
80
|
* Read scope as configured for the account. Left `undefined` when the key is
|
|
155
81
|
* absent so `isChatReadable` can tell "not configured" from "configured empty" —
|
|
@@ -244,134 +170,11 @@ function readAccountManageChats(account) {
|
|
|
244
170
|
const entries = Array.isArray(raw) ? raw : [raw];
|
|
245
171
|
return entries.map((entry) => String(entry).trim()).filter(Boolean);
|
|
246
172
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
}
|
|
173
|
+
const actions_1 = require("./actions");
|
|
174
|
+
Object.defineProperty(exports, "CORE_ACTION_SYNONYMS", { enumerable: true, get: function () { return actions_1.CORE_ACTION_SYNONYMS; } });
|
|
175
|
+
Object.defineProperty(exports, "canonicalAction", { enumerable: true, get: function () { return actions_1.canonicalAction; } });
|
|
176
|
+
const outbound_1 = require("./outbound");
|
|
177
|
+
const inbound_pipeline_1 = require("./inbound-pipeline");
|
|
375
178
|
/**
|
|
376
179
|
* Turns an inbound attachment into text the agent can read.
|
|
377
180
|
*
|
|
@@ -385,126 +188,7 @@ function parseOptionalThreadId(value) {
|
|
|
385
188
|
* "you sent something I could not read" than staying silent, which is
|
|
386
189
|
* indistinguishable from being offline.
|
|
387
190
|
*/
|
|
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
|
-
}
|
|
191
|
+
const attachments_1 = require("./attachments");
|
|
508
192
|
const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
509
193
|
const resolveRuntimeAccountId = (cfg, preferred) => {
|
|
510
194
|
const configured = (0, helpers_1.resolveConfiguredAccountId)(cfg, preferred);
|
|
@@ -516,6 +200,20 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
516
200
|
}
|
|
517
201
|
return configured ?? runtimes.keys().next().value;
|
|
518
202
|
};
|
|
203
|
+
/**
|
|
204
|
+
* The connected runtime for an account, or a refusal naming it.
|
|
205
|
+
*
|
|
206
|
+
* One helper instead of the eleven copies of this three-liner that used to
|
|
207
|
+
* sit inside each dispatch branch — the same repetition that made every new
|
|
208
|
+
* action cost a scaffold (finding A6-11).
|
|
209
|
+
*/
|
|
210
|
+
const requireRuntimeFor = (id) => {
|
|
211
|
+
const gram = runtimes.get(id);
|
|
212
|
+
if (!gram) {
|
|
213
|
+
throw new Error(`clawgram: runtime not found for account ${id}`);
|
|
214
|
+
}
|
|
215
|
+
return gram;
|
|
216
|
+
};
|
|
519
217
|
return {
|
|
520
218
|
id: "clawgram",
|
|
521
219
|
meta: {
|
|
@@ -689,808 +387,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
689
387
|
});
|
|
690
388
|
const client = gram.getClient();
|
|
691
389
|
const eventBuilder = new events_1.NewMessage({});
|
|
692
|
-
const eventHandler = async (event) => {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const rawPeerChatId = rawMessage?.peerId?.chatId;
|
|
697
|
-
const rawPeerChannelId = rawMessage?.peerId?.channelId;
|
|
698
|
-
const directLike = rawPeerUserId !== undefined ||
|
|
699
|
-
(typeof rawMessage?.chatId === "number" && rawMessage.chatId > 0);
|
|
700
|
-
if (directLike) {
|
|
701
|
-
log?.info?.("clawgram raw direct-like event", {
|
|
702
|
-
accountId,
|
|
703
|
-
messageId: String(rawMessage?.id ?? ""),
|
|
704
|
-
chatId: String(rawMessage?.chatId ?? ""),
|
|
705
|
-
peerUserId: String(rawPeerUserId ?? ""),
|
|
706
|
-
peerChatId: String(rawPeerChatId ?? ""),
|
|
707
|
-
peerChannelId: String(rawPeerChannelId ?? ""),
|
|
708
|
-
senderId: String(rawMessage?.senderId ?? rawMessage?.fromId?.userId ?? ""),
|
|
709
|
-
out: rawMessage?.out === true,
|
|
710
|
-
textLength: typeof rawMessage?.message === "string" ? rawMessage.message.length : typeof rawMessage?.text === "string" ? rawMessage.text.length : 0,
|
|
711
|
-
});
|
|
712
|
-
}
|
|
713
|
-
const normalized = (0, normalize_1.normalizeTelegramEvent)(event, accountId);
|
|
714
|
-
if (!normalized) {
|
|
715
|
-
if (directLike) {
|
|
716
|
-
log?.info?.("clawgram normalize returned null", {
|
|
717
|
-
accountId,
|
|
718
|
-
messageId: String(rawMessage?.id ?? ""),
|
|
719
|
-
chatId: String(rawMessage?.chatId ?? ""),
|
|
720
|
-
peerUserId: String(rawPeerUserId ?? ""),
|
|
721
|
-
});
|
|
722
|
-
}
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
const directReplyTarget = normalized.chatType === "direct"
|
|
726
|
-
? undefined
|
|
727
|
-
: await (0, helpers_1.resolveReplyTarget)(rawMessage);
|
|
728
|
-
const senderProfile = normalized.chatType === "direct"
|
|
729
|
-
? await (0, helpers_1.resolveSenderProfileWithTimeout)(rawMessage, {
|
|
730
|
-
senderId: normalized.senderId,
|
|
731
|
-
client,
|
|
732
|
-
}, 1500)
|
|
733
|
-
: await (0, helpers_1.resolveSenderProfile)(rawMessage, {
|
|
734
|
-
senderId: normalized.senderId,
|
|
735
|
-
client,
|
|
736
|
-
});
|
|
737
|
-
const replyTarget = normalized.chatType === "direct"
|
|
738
|
-
? normalized.chatId
|
|
739
|
-
: await (0, helpers_1.resolveChatTarget)(rawMessage);
|
|
740
|
-
if (replyTarget) {
|
|
741
|
-
normalized.replyTarget = replyTarget;
|
|
742
|
-
}
|
|
743
|
-
if (!normalized.senderUsername && senderProfile.username) {
|
|
744
|
-
normalized.senderUsername = senderProfile.username;
|
|
745
|
-
}
|
|
746
|
-
if (!normalized.senderDisplay && senderProfile.display) {
|
|
747
|
-
normalized.senderDisplay = senderProfile.display;
|
|
748
|
-
}
|
|
749
|
-
if (normalized.isOutgoing) {
|
|
750
|
-
if (normalized.chatType === "direct") {
|
|
751
|
-
log?.info?.("clawgram skipping outgoing direct event", {
|
|
752
|
-
accountId,
|
|
753
|
-
chatId: normalized.chatId,
|
|
754
|
-
messageId: normalized.messageId,
|
|
755
|
-
senderId: normalized.senderId,
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
if (normalized.chatType === "channel") {
|
|
761
|
-
log?.info?.("clawgram skipping channel inbound", {
|
|
762
|
-
accountId,
|
|
763
|
-
chatId: normalized.chatId,
|
|
764
|
-
chatType: normalized.chatType,
|
|
765
|
-
messageId: normalized.messageId,
|
|
766
|
-
});
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
let text = normalized.text?.trim();
|
|
770
|
-
// Whether this sender may reach the agent at all — decided before
|
|
771
|
-
// the attachment is fetched.
|
|
772
|
-
//
|
|
773
|
-
// Reading an attachment downloads up to 25 MB and then spends a
|
|
774
|
-
// transcription or vision call on it. That used to happen for
|
|
775
|
-
// every photo and voice note from anyone in any group the account
|
|
776
|
-
// sits in, and only afterwards was the sender checked against
|
|
777
|
-
// `allowFrom`. A stranger could therefore spend the owner's model
|
|
778
|
-
// budget at will. None of these checks depend on the message text,
|
|
779
|
-
// so they cost nothing to run first.
|
|
780
|
-
const inboundSenderId = normalized.senderId ?? normalized.chatId;
|
|
781
|
-
const inboundScopes = (0, helpers_1.resolveAccountScopes)(cfg, accountId);
|
|
782
|
-
const inboundGroupConfig = normalized.chatType === "group"
|
|
783
|
-
? (0, helpers_1.resolveGroupConfig)(inboundScopes.groups, normalized.chatId)
|
|
784
|
-
: undefined;
|
|
785
|
-
const senderMayReachAgent = normalized.chatType === "group"
|
|
786
|
-
? Boolean(inboundGroupConfig
|
|
787
|
-
&& inboundGroupConfig.enabled !== false
|
|
788
|
-
&& (0, helpers_1.isSenderAllowed)({
|
|
789
|
-
allowFrom: inboundGroupConfig.allowFrom,
|
|
790
|
-
senderId: inboundSenderId,
|
|
791
|
-
senderUsername: normalized.senderUsername,
|
|
792
|
-
}))
|
|
793
|
-
: (0, helpers_1.isSenderAllowed)({
|
|
794
|
-
allowFrom: inboundScopes.allowFrom,
|
|
795
|
-
senderId: inboundSenderId,
|
|
796
|
-
senderUsername: normalized.senderUsername,
|
|
797
|
-
});
|
|
798
|
-
// An attachment carries no text of its own, and dropping it as
|
|
799
|
-
// "empty" is how the assistant used to go silent on being spoken
|
|
800
|
-
// to or shown something. Read it into the body instead: for a
|
|
801
|
-
// voice note and a screenshot alike, the attachment *is* the
|
|
802
|
-
// message. A caption is kept and the reading appended, because
|
|
803
|
-
// "look at this" plus the picture is one thought, not two.
|
|
804
|
-
const attachment = senderMayReachAgent ? await readInboundAttachment({
|
|
805
|
-
gram,
|
|
806
|
-
event,
|
|
807
|
-
cfg,
|
|
808
|
-
runtime: pluginRuntime,
|
|
809
|
-
log,
|
|
810
|
-
accountId,
|
|
811
|
-
chatId: normalized.chatId,
|
|
812
|
-
messageId: normalized.messageId,
|
|
813
|
-
}) : undefined;
|
|
814
|
-
if (attachment) {
|
|
815
|
-
const marker = attachment.understanding === "transcript" ? "голосовое" : "изображение";
|
|
816
|
-
const read = `[${marker}] ${attachment.text}`;
|
|
817
|
-
text = text ? `${text}\n\n${read}` : read;
|
|
818
|
-
}
|
|
819
|
-
// What the mention gate is allowed to read.
|
|
820
|
-
//
|
|
821
|
-
// A transcript is the sender's own speech, so "Тина, посмотри"
|
|
822
|
-
// said aloud addresses the agent exactly as typing it would. A
|
|
823
|
-
// description is not: it is a vision model reading somebody
|
|
824
|
-
// else's content, and a screenshot of a chat where a third party
|
|
825
|
-
// wrote "@tina_bot" is not an address to her. Feeding the whole
|
|
826
|
-
// body to the gate made every such screenshot wake her up.
|
|
827
|
-
const addressableText = (0, helpers_1.resolveAddressableText)({
|
|
828
|
-
messageText: normalized.text,
|
|
829
|
-
bodyText: text,
|
|
830
|
-
understanding: attachment?.understanding,
|
|
831
|
-
});
|
|
832
|
-
if (!text) {
|
|
833
|
-
log?.info?.("clawgram skipping empty inbound text", {
|
|
834
|
-
accountId,
|
|
835
|
-
chatId: normalized.chatId,
|
|
836
|
-
messageId: normalized.messageId,
|
|
837
|
-
});
|
|
838
|
-
return;
|
|
839
|
-
}
|
|
840
|
-
const senderId = normalized.senderId ?? normalized.chatId;
|
|
841
|
-
const isTelegramServiceDirect = normalized.chatType === "direct" &&
|
|
842
|
-
(normalized.chatId === constants_1.TELEGRAM_SERVICE_CHAT_ID || senderId === constants_1.TELEGRAM_SERVICE_CHAT_ID);
|
|
843
|
-
const isSavedMessagesDirect = normalized.chatType === "direct" &&
|
|
844
|
-
Boolean(selfId) &&
|
|
845
|
-
normalized.chatId === selfId &&
|
|
846
|
-
senderId === selfId;
|
|
847
|
-
if (isTelegramServiceDirect) {
|
|
848
|
-
log?.info?.("clawgram skipping Telegram service direct chat", {
|
|
849
|
-
accountId,
|
|
850
|
-
chatId: normalized.chatId,
|
|
851
|
-
messageId: normalized.messageId,
|
|
852
|
-
senderId,
|
|
853
|
-
});
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
856
|
-
if (isSavedMessagesDirect) {
|
|
857
|
-
log?.info?.("clawgram skipping Saved Messages direct chat", {
|
|
858
|
-
accountId,
|
|
859
|
-
chatId: normalized.chatId,
|
|
860
|
-
messageId: normalized.messageId,
|
|
861
|
-
senderId,
|
|
862
|
-
selfId,
|
|
863
|
-
});
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
const senderUsername = normalized.senderUsername;
|
|
867
|
-
const senderLabel = normalized.senderDisplay || normalized.senderUsername || senderId;
|
|
868
|
-
const conversationTarget = normalized.chatType === "direct"
|
|
869
|
-
? normalized.chatId
|
|
870
|
-
: normalized.replyTarget ?? normalized.chatId;
|
|
871
|
-
const conversationFallbackTargets = [
|
|
872
|
-
normalized.chatType === "direct" ? directReplyTarget : undefined,
|
|
873
|
-
normalized.chatType === "direct" ? normalized.replyTarget : undefined,
|
|
874
|
-
normalized.chatType === "direct" && normalized.senderUsername ? `@${normalized.senderUsername}` : undefined,
|
|
875
|
-
normalized.chatId,
|
|
876
|
-
].filter((target, index, items) => {
|
|
877
|
-
if (!target || target === conversationTarget) {
|
|
878
|
-
return false;
|
|
879
|
-
}
|
|
880
|
-
return items.findIndex((candidate) => candidate === target) === index;
|
|
881
|
-
});
|
|
882
|
-
const sendTextToConversation = async (args) => {
|
|
883
|
-
const targets = [conversationTarget, ...conversationFallbackTargets];
|
|
884
|
-
// Replies have no per-call parseMode slot — the format is an
|
|
885
|
-
// account setting (2.3.1); absent keeps the GramJS default
|
|
886
|
-
// (its markdown parser — not plain text, see 2.15.0 notes).
|
|
887
|
-
const replyParseMode = gram.replyParseMode;
|
|
888
|
-
let lastError;
|
|
889
|
-
for (const target of targets) {
|
|
890
|
-
try {
|
|
891
|
-
return await gram.sendText({
|
|
892
|
-
target,
|
|
893
|
-
text: args.text,
|
|
894
|
-
replyToMessageId: args.replyToMessageId,
|
|
895
|
-
messageThreadId: args.messageThreadId,
|
|
896
|
-
parseMode: replyParseMode,
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
|
-
catch (error) {
|
|
900
|
-
lastError = error;
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
throw lastError;
|
|
904
|
-
};
|
|
905
|
-
// Resolved once, above, before the attachment fetch that depends on
|
|
906
|
-
// the answer — and by the same resolver `resolveAccount` uses, so the
|
|
907
|
-
// gate applied here is the one the account was started with.
|
|
908
|
-
const { allowFrom: directAllowFrom } = inboundScopes;
|
|
909
|
-
const dmPolicy = "open";
|
|
910
|
-
if (normalized.chatType === "group") {
|
|
911
|
-
const groupConfig = inboundGroupConfig;
|
|
912
|
-
if (!groupConfig) {
|
|
913
|
-
log?.info?.("clawgram skipping group not present in groups config", {
|
|
914
|
-
accountId,
|
|
915
|
-
chatId: normalized.chatId,
|
|
916
|
-
messageId: normalized.messageId,
|
|
917
|
-
});
|
|
918
|
-
return;
|
|
919
|
-
}
|
|
920
|
-
if (groupConfig.enabled === false) {
|
|
921
|
-
log?.info?.("clawgram skipping disabled group", {
|
|
922
|
-
accountId,
|
|
923
|
-
chatId: normalized.chatId,
|
|
924
|
-
messageId: normalized.messageId,
|
|
925
|
-
});
|
|
926
|
-
return;
|
|
927
|
-
}
|
|
928
|
-
if (!(0, helpers_1.isSenderAllowed)({
|
|
929
|
-
allowFrom: groupConfig.allowFrom,
|
|
930
|
-
senderId,
|
|
931
|
-
senderUsername: normalized.senderUsername,
|
|
932
|
-
})) {
|
|
933
|
-
log?.info?.("clawgram blocking inbound group sender by allowFrom", {
|
|
934
|
-
accountId,
|
|
935
|
-
chatId: normalized.chatId,
|
|
936
|
-
messageId: normalized.messageId,
|
|
937
|
-
senderId,
|
|
938
|
-
username: normalized.senderUsername,
|
|
939
|
-
allowFrom: groupConfig.allowFrom,
|
|
940
|
-
});
|
|
941
|
-
return;
|
|
942
|
-
}
|
|
943
|
-
const scopedGroupPeerId = (0, helpers_1.buildScopedGroupPeerId)(accountId, normalized.chatId);
|
|
944
|
-
const { route: inboundRoute, buildEnvelope } = (0, inbound_envelope_1.resolveInboundRouteEnvelopeBuilderWithRuntime)({
|
|
945
|
-
cfg,
|
|
946
|
-
channel: "clawgram",
|
|
947
|
-
accountId,
|
|
948
|
-
peer: {
|
|
949
|
-
kind: "group",
|
|
950
|
-
id: scopedGroupPeerId,
|
|
951
|
-
},
|
|
952
|
-
runtime: channelRuntime,
|
|
953
|
-
sessionStore: cfg?.session?.store,
|
|
954
|
-
});
|
|
955
|
-
// channelRuntime comes from the untyped ctx, so the generic route type falls
|
|
956
|
-
// back to the minimal RouteLike. The runtime value is a ResolvedAgentRoute.
|
|
957
|
-
const route = inboundRoute;
|
|
958
|
-
// Under `tag` the name is not an address: in a chat of a thousand
|
|
959
|
-
// people it occurs in conversation constantly and is aimed at her
|
|
960
|
-
// almost never. Only the `@` counts, and it is the same fact the
|
|
961
|
-
// stricter rung of the ladder is named after.
|
|
962
|
-
const wasMentioned = groupConfig.groupPolicy === "tag"
|
|
963
|
-
? (0, helpers_1.hasExplicitTelegramMention)({ selfUsername, text: addressableText, message: rawMessage })
|
|
964
|
-
: (0, helpers_1.hasTelegramMention)({
|
|
965
|
-
cfg,
|
|
966
|
-
agentId: route.agentId,
|
|
967
|
-
selfUsername,
|
|
968
|
-
text: addressableText,
|
|
969
|
-
message: rawMessage,
|
|
970
|
-
});
|
|
971
|
-
// One fetch serves two needs: the reply-to-self gate below and
|
|
972
|
-
// the parent's text for the agent (ReplyToBody), which a plain
|
|
973
|
-
// reply does not carry on its own.
|
|
974
|
-
const replyParent = await (0, helpers_1.resolveReplyParent)(rawMessage, { selfId, selfLabel });
|
|
975
|
-
const wasReplyToSelf = replyParent.isSelf;
|
|
976
|
-
const mentionDecision = (0, channel_inbound_1.resolveInboundMentionDecision)({
|
|
977
|
-
facts: {
|
|
978
|
-
canDetectMention: true,
|
|
979
|
-
wasMentioned,
|
|
980
|
-
hasAnyMention: /(^|\s)@[a-zA-Z0-9_]{5,}\b/.test(addressableText),
|
|
981
|
-
},
|
|
982
|
-
policy: {
|
|
983
|
-
isGroup: true,
|
|
984
|
-
requireMention: groupConfig.groupPolicy !== "open",
|
|
985
|
-
allowTextCommands: false,
|
|
986
|
-
hasControlCommand: false,
|
|
987
|
-
commandAuthorized: true,
|
|
988
|
-
},
|
|
989
|
-
});
|
|
990
|
-
log?.info?.("clawgram group mention gate", {
|
|
991
|
-
accountId,
|
|
992
|
-
chatId: normalized.chatId,
|
|
993
|
-
messageId: normalized.messageId,
|
|
994
|
-
selfUsername,
|
|
995
|
-
groupPolicy: groupConfig.groupPolicy,
|
|
996
|
-
mentionedFlag: rawMessage?.mentioned === true,
|
|
997
|
-
hasEntities: Array.isArray(rawMessage?.entities) ? rawMessage.entities.length : 0,
|
|
998
|
-
wasMentioned,
|
|
999
|
-
wasReplyToSelf,
|
|
1000
|
-
shouldSkip: mentionDecision.shouldSkip,
|
|
1001
|
-
textLength: text.length,
|
|
1002
|
-
});
|
|
1003
|
-
if (groupConfig.groupPolicy !== "open" && mentionDecision.shouldSkip && !wasReplyToSelf) {
|
|
1004
|
-
log?.info?.("clawgram skipping group message without mention", {
|
|
1005
|
-
accountId,
|
|
1006
|
-
chatId: normalized.chatId,
|
|
1007
|
-
messageId: normalized.messageId,
|
|
1008
|
-
senderId,
|
|
1009
|
-
});
|
|
1010
|
-
return;
|
|
1011
|
-
}
|
|
1012
|
-
const { storePath, body } = buildEnvelope({
|
|
1013
|
-
channel: "Telegram",
|
|
1014
|
-
from: senderLabel,
|
|
1015
|
-
body: text,
|
|
1016
|
-
timestamp: normalized.timestamp,
|
|
1017
|
-
});
|
|
1018
|
-
const conversationRouteTarget = (0, helpers_1.buildConversationTarget)(normalized.chatId);
|
|
1019
|
-
const ctxPayload = channelRuntime.reply.finalizeInboundContext({
|
|
1020
|
-
Body: body,
|
|
1021
|
-
BodyForAgent: text,
|
|
1022
|
-
RawBody: text,
|
|
1023
|
-
CommandBody: text,
|
|
1024
|
-
From: conversationRouteTarget,
|
|
1025
|
-
To: conversationRouteTarget,
|
|
1026
|
-
SessionKey: route.sessionKey,
|
|
1027
|
-
AccountId: route.accountId ?? accountId,
|
|
1028
|
-
ChatType: "group",
|
|
1029
|
-
ConversationLabel: senderLabel,
|
|
1030
|
-
SenderId: senderId,
|
|
1031
|
-
SenderUsername: normalized.senderUsername,
|
|
1032
|
-
SenderName: normalized.senderDisplay,
|
|
1033
|
-
GroupId: normalized.chatId,
|
|
1034
|
-
GroupSubject: normalized.chatId,
|
|
1035
|
-
WasMentioned: mentionDecision.effectiveWasMentioned || wasReplyToSelf,
|
|
1036
|
-
WasReplyToSelf: wasReplyToSelf,
|
|
1037
|
-
Provider: "telegram",
|
|
1038
|
-
Surface: "clawgram",
|
|
1039
|
-
MessageSid: normalized.messageId,
|
|
1040
|
-
MessageSidFull: normalized.messageId,
|
|
1041
|
-
Timestamp: normalized.timestamp,
|
|
1042
|
-
ReplyToId: normalized.replyToMessageId,
|
|
1043
|
-
// Core renders these itself as `[Replying to: "…"]` ahead of the
|
|
1044
|
-
// user body — it keys off Provider being "telegram", which is set
|
|
1045
|
-
// below. Without them a highlighted reply reaches the agent as
|
|
1046
|
-
// bare text, and the fragment the person pointed at is lost.
|
|
1047
|
-
ReplyToQuoteText: normalized.replyQuoteText,
|
|
1048
|
-
ReplyToIsQuote: normalized.replyIsQuote,
|
|
1049
|
-
// A plain reply has no highlight; core then falls back to the
|
|
1050
|
-
// parent's body, which only exists if the channel fetched it.
|
|
1051
|
-
ReplyToBody: replyParent.body,
|
|
1052
|
-
ReplyToSender: replyParent.sender,
|
|
1053
|
-
MessageThreadId: normalized.messageThreadId,
|
|
1054
|
-
NativeChannelId: normalized.chatId,
|
|
1055
|
-
// Trusted per-group prompt block from `groups.<id>.systemPrompt`.
|
|
1056
|
-
// Core normalizes it (`normalizeTrustedTextField`) and appends
|
|
1057
|
-
// it to the system prompt for this turn. Undefined = no block.
|
|
1058
|
-
GroupSystemPrompt: groupConfig.systemPrompt,
|
|
1059
|
-
OriginatingChannel: "clawgram",
|
|
1060
|
-
OriginatingTo: conversationRouteTarget,
|
|
1061
|
-
});
|
|
1062
|
-
const groupReplyAddress = (0, group_reply_address_1.buildGroupReplyAddress)({
|
|
1063
|
-
senderUsername: normalized.senderUsername,
|
|
1064
|
-
senderDisplay: normalized.senderDisplay,
|
|
1065
|
-
senderId,
|
|
1066
|
-
});
|
|
1067
|
-
(0, group_reply_address_1.rememberGroupReplyAddress)({
|
|
1068
|
-
accountId: route.accountId ?? accountId,
|
|
1069
|
-
chatId: normalized.chatId,
|
|
1070
|
-
replyToId: normalized.messageId,
|
|
1071
|
-
address: groupReplyAddress,
|
|
1072
|
-
});
|
|
1073
|
-
const messageThreadId = parseOptionalThreadId(normalized.messageThreadId);
|
|
1074
|
-
const groupTypingTarget = normalized.chatId;
|
|
1075
|
-
await gram.withTyping(groupTypingTarget, async () => {
|
|
1076
|
-
log?.info?.("clawgram dispatching group reply", {
|
|
1077
|
-
accountId,
|
|
1078
|
-
chatId: normalized.chatId,
|
|
1079
|
-
messageId: normalized.messageId,
|
|
1080
|
-
routeSessionKey: route.sessionKey,
|
|
1081
|
-
storePath,
|
|
1082
|
-
});
|
|
1083
|
-
await channelRuntime.session.recordInboundSession({
|
|
1084
|
-
storePath,
|
|
1085
|
-
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
1086
|
-
ctx: ctxPayload,
|
|
1087
|
-
updateLastRoute: {
|
|
1088
|
-
sessionKey: route.sessionKey,
|
|
1089
|
-
channel: constants_2.CHANNEL_ID,
|
|
1090
|
-
to: conversationRouteTarget,
|
|
1091
|
-
accountId: route.accountId ?? accountId,
|
|
1092
|
-
},
|
|
1093
|
-
onRecordError: (err) => {
|
|
1094
|
-
log?.info?.("clawgram failed to update group last route", {
|
|
1095
|
-
accountId,
|
|
1096
|
-
chatId: normalized.chatId,
|
|
1097
|
-
messageId: normalized.messageId,
|
|
1098
|
-
error: String(err),
|
|
1099
|
-
});
|
|
1100
|
-
},
|
|
1101
|
-
});
|
|
1102
|
-
const dispatchBase = (0, inbound_reply_dispatch_1.buildInboundReplyDispatchBase)({
|
|
1103
|
-
cfg,
|
|
1104
|
-
channel: "clawgram",
|
|
1105
|
-
accountId: route.accountId ?? accountId,
|
|
1106
|
-
route,
|
|
1107
|
-
storePath,
|
|
1108
|
-
ctxPayload,
|
|
1109
|
-
core: { channel: channelRuntime },
|
|
1110
|
-
});
|
|
1111
|
-
const { onModelSelected, ...replyPipeline } = (0, channel_reply_pipeline_1.createChannelReplyPipeline)({
|
|
1112
|
-
cfg,
|
|
1113
|
-
agentId: route.agentId,
|
|
1114
|
-
channel: "clawgram",
|
|
1115
|
-
accountId: route.accountId ?? accountId,
|
|
1116
|
-
});
|
|
1117
|
-
// Boundary for the transcript fallback below: only replies
|
|
1118
|
-
// written after this instant may be salvaged. Same clock as
|
|
1119
|
-
// the transcript writer — both live in this process.
|
|
1120
|
-
const dispatchStartedAt = Date.now();
|
|
1121
|
-
const dispatchResult = await dispatchBase.dispatchReplyWithBufferedBlockDispatcher({
|
|
1122
|
-
ctx: ctxPayload,
|
|
1123
|
-
cfg,
|
|
1124
|
-
dispatcherOptions: {
|
|
1125
|
-
...replyPipeline,
|
|
1126
|
-
deliver: async (payload) => {
|
|
1127
|
-
const outboundText = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
1128
|
-
log?.info?.("clawgram deliver group payload", {
|
|
1129
|
-
accountId,
|
|
1130
|
-
chatId: normalized.chatId,
|
|
1131
|
-
messageId: normalized.messageId,
|
|
1132
|
-
payloadTextLength: outboundText.length,
|
|
1133
|
-
payloadReplyToId: payload.replyToId ?? null,
|
|
1134
|
-
});
|
|
1135
|
-
if (!outboundText) {
|
|
1136
|
-
return;
|
|
1137
|
-
}
|
|
1138
|
-
// The agent may decline to answer by returning the shared
|
|
1139
|
-
// silent token. Drop it before addressing: otherwise the
|
|
1140
|
-
// reply-address prefix turns it into a visible message.
|
|
1141
|
-
const visibleText = (0, helpers_1.stripSilentReplyToken)(outboundText);
|
|
1142
|
-
if (!visibleText) {
|
|
1143
|
-
log?.info?.("clawgram suppressing silent group reply", {
|
|
1144
|
-
accountId,
|
|
1145
|
-
chatId: normalized.chatId,
|
|
1146
|
-
messageId: normalized.messageId,
|
|
1147
|
-
});
|
|
1148
|
-
return;
|
|
1149
|
-
}
|
|
1150
|
-
// Ядро подклеивает свою телеметрию к полезной нагрузке
|
|
1151
|
-
// хода, и сюда она приходит тем же путём, что ответ.
|
|
1152
|
-
// Проверка стояла только в `outbound.sendText`, то есть
|
|
1153
|
-
// класс инцидента 30.08–01.09 был закрыт для рассылок и
|
|
1154
|
-
// открыт для обычного ответа на упоминание (A5-10).
|
|
1155
|
-
const groupNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
|
|
1156
|
-
targetKind: "group",
|
|
1157
|
-
text: visibleText,
|
|
1158
|
-
});
|
|
1159
|
-
if (groupNotice) {
|
|
1160
|
-
log?.warn?.("clawgram suppressing system notice in group reply", {
|
|
1161
|
-
accountId,
|
|
1162
|
-
chatId: normalized.chatId,
|
|
1163
|
-
messageId: normalized.messageId,
|
|
1164
|
-
noticeKind: groupNotice,
|
|
1165
|
-
textLength: visibleText.length,
|
|
1166
|
-
});
|
|
1167
|
-
return;
|
|
1168
|
-
}
|
|
1169
|
-
const replyToMessageId = payload.replyToId ? Number(payload.replyToId) : Number(normalized.messageId);
|
|
1170
|
-
const rememberedAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
|
|
1171
|
-
accountId: route.accountId ?? accountId,
|
|
1172
|
-
chatId: normalized.chatId,
|
|
1173
|
-
replyToId: payload.replyToId ?? normalized.messageId,
|
|
1174
|
-
});
|
|
1175
|
-
await sendTextToConversation({
|
|
1176
|
-
text: (0, helpers_1.prefixReplyTextToAddress)(visibleText, rememberedAddress ?? groupReplyAddress),
|
|
1177
|
-
replyToMessageId,
|
|
1178
|
-
messageThreadId,
|
|
1179
|
-
});
|
|
1180
|
-
},
|
|
1181
|
-
onError: (err, info) => {
|
|
1182
|
-
log?.error?.("clawgram failed to dispatch group reply", {
|
|
1183
|
-
accountId,
|
|
1184
|
-
chatId: normalized.chatId,
|
|
1185
|
-
messageId: normalized.messageId,
|
|
1186
|
-
kind: info.kind,
|
|
1187
|
-
error: String(err),
|
|
1188
|
-
});
|
|
1189
|
-
},
|
|
1190
|
-
},
|
|
1191
|
-
replyOptions: {
|
|
1192
|
-
onModelSelected,
|
|
1193
|
-
// `groups.<id>.skills` → core's per-turn skill allowlist.
|
|
1194
|
-
// Undefined = inherit the agent's skills; [] = none here.
|
|
1195
|
-
skillFilter: groupConfig.skillFilter,
|
|
1196
|
-
},
|
|
1197
|
-
});
|
|
1198
|
-
log?.info?.("clawgram group dispatch completed", {
|
|
1199
|
-
accountId,
|
|
1200
|
-
chatId: normalized.chatId,
|
|
1201
|
-
messageId: normalized.messageId,
|
|
1202
|
-
queuedFinal: dispatchResult?.queuedFinal ?? null,
|
|
1203
|
-
counts: dispatchResult?.counts ?? null,
|
|
1204
|
-
});
|
|
1205
|
-
const dispatchCounts = dispatchResult?.counts ?? { tool: 0, block: 0, final: 0 };
|
|
1206
|
-
const nothingDelivered = dispatchResult?.queuedFinal !== true &&
|
|
1207
|
-
(dispatchCounts.tool ?? 0) === 0 &&
|
|
1208
|
-
(dispatchCounts.block ?? 0) === 0 &&
|
|
1209
|
-
(dispatchCounts.final ?? 0) === 0;
|
|
1210
|
-
if (nothingDelivered) {
|
|
1211
|
-
const fallbackText = (0, helpers_1.readLatestAssistantFallbackFromTranscript)(route.sessionKey, storePath, dispatchStartedAt);
|
|
1212
|
-
// A suppressed silent reply legitimately delivers nothing, so
|
|
1213
|
-
// this fallback fires right after it. Without the same check
|
|
1214
|
-
// the token would be read back from the transcript and sent.
|
|
1215
|
-
//
|
|
1216
|
-
// TTS markup needs the same treatment for the same reason:
|
|
1217
|
-
// core strips it on the normal reply path, but this text comes
|
|
1218
|
-
// straight out of the transcript. On 2026-08-08 a group got
|
|
1219
|
-
// `[[tts:text]]Привет, Вася!…[[/tts:text]]` verbatim. The
|
|
1220
|
-
// spoken words are kept — a synthesis that did not happen
|
|
1221
|
-
// should degrade to readable text, not to markup.
|
|
1222
|
-
const rawFallback = fallbackText
|
|
1223
|
-
? (0, helpers_1.stripTtsDirectives)((0, helpers_1.stripSilentReplyToken)(fallbackText))
|
|
1224
|
-
: "";
|
|
1225
|
-
// Тот же фильтр и здесь: последняя реплика в стенограмме
|
|
1226
|
-
// вполне может оказаться именно уведомлением об ошибке.
|
|
1227
|
-
const fallbackNotice = rawFallback
|
|
1228
|
-
? (0, system_notice_1.shouldSuppressGroupSystemNotice)({ targetKind: "group", text: rawFallback })
|
|
1229
|
-
: undefined;
|
|
1230
|
-
if (fallbackNotice) {
|
|
1231
|
-
log?.warn?.("clawgram suppressing system notice in transcript fallback", {
|
|
1232
|
-
accountId,
|
|
1233
|
-
chatId: normalized.chatId,
|
|
1234
|
-
messageId: normalized.messageId,
|
|
1235
|
-
noticeKind: fallbackNotice,
|
|
1236
|
-
textLength: rawFallback.length,
|
|
1237
|
-
});
|
|
1238
|
-
}
|
|
1239
|
-
const visibleFallbackText = fallbackNotice ? "" : rawFallback;
|
|
1240
|
-
if (!visibleFallbackText) {
|
|
1241
|
-
if (fallbackText) {
|
|
1242
|
-
log?.info?.("clawgram skipping silent transcript fallback", {
|
|
1243
|
-
accountId,
|
|
1244
|
-
chatId: normalized.chatId,
|
|
1245
|
-
messageId: normalized.messageId,
|
|
1246
|
-
routeSessionKey: route.sessionKey,
|
|
1247
|
-
});
|
|
1248
|
-
}
|
|
1249
|
-
else {
|
|
1250
|
-
log?.warn?.("clawgram transcript fallback unavailable", {
|
|
1251
|
-
accountId,
|
|
1252
|
-
chatId: normalized.chatId,
|
|
1253
|
-
messageId: normalized.messageId,
|
|
1254
|
-
routeSessionKey: route.sessionKey,
|
|
1255
|
-
});
|
|
1256
|
-
}
|
|
1257
|
-
// Named, and nothing came back: leave a reaction so the
|
|
1258
|
-
// decision is visible instead of reading as her ignoring
|
|
1259
|
-
// people. The condition is her silence, not the shape of
|
|
1260
|
-
// the transcript — a turn that wrote no entry at all is
|
|
1261
|
-
// just as silent as one that wrote the NO_REPLY token.
|
|
1262
|
-
//
|
|
1263
|
-
// Never allowed to disturb the turn: the reply is already
|
|
1264
|
-
// settled by this point, so a failure here stays silent.
|
|
1265
|
-
await reactToSilentMentionForAccount({
|
|
1266
|
-
cfg,
|
|
1267
|
-
accountId,
|
|
1268
|
-
gram: runtimes.get(accountId),
|
|
1269
|
-
pluginRuntime,
|
|
1270
|
-
chatId: normalized.chatId,
|
|
1271
|
-
messageId: normalized.messageId,
|
|
1272
|
-
messageText: normalized.text,
|
|
1273
|
-
// Same sense of "addressed" the agent was given for this
|
|
1274
|
-
// turn on line 817: a reply to her own message counts as
|
|
1275
|
-
// being spoken to, mention or not.
|
|
1276
|
-
wasMentioned: mentionDecision.effectiveWasMentioned || wasReplyToSelf,
|
|
1277
|
-
}).catch((err) => {
|
|
1278
|
-
log?.info?.("clawgram silent-mention reaction failed", {
|
|
1279
|
-
accountId,
|
|
1280
|
-
chatId: normalized.chatId,
|
|
1281
|
-
messageId: normalized.messageId,
|
|
1282
|
-
error: String(err),
|
|
1283
|
-
});
|
|
1284
|
-
});
|
|
1285
|
-
}
|
|
1286
|
-
else {
|
|
1287
|
-
log?.warn?.("clawgram using transcript fallback reply", {
|
|
1288
|
-
accountId,
|
|
1289
|
-
chatId: normalized.chatId,
|
|
1290
|
-
messageId: normalized.messageId,
|
|
1291
|
-
routeSessionKey: route.sessionKey,
|
|
1292
|
-
fallbackTextLength: visibleFallbackText.length,
|
|
1293
|
-
});
|
|
1294
|
-
await sendTextToConversation({
|
|
1295
|
-
text: (0, helpers_1.prefixReplyTextToAddress)(visibleFallbackText, groupReplyAddress),
|
|
1296
|
-
replyToMessageId: Number(normalized.messageId),
|
|
1297
|
-
messageThreadId,
|
|
1298
|
-
});
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
}, {
|
|
1302
|
-
readMessageId: Number(normalized.messageId),
|
|
1303
|
-
messageThreadId,
|
|
1304
|
-
// The indicator is a promise of an answer, and it is owed only
|
|
1305
|
-
// to someone who addressed her. Under `open` the turn runs on
|
|
1306
|
-
// every message in the chat, so without this the whole room
|
|
1307
|
-
// watches her "type" through conversations she is only reading.
|
|
1308
|
-
typing: mentionDecision.effectiveWasMentioned || wasReplyToSelf,
|
|
1309
|
-
});
|
|
1310
|
-
log?.info?.("clawgram group inbound handled", {
|
|
1311
|
-
accountId,
|
|
1312
|
-
chatId: normalized.chatId,
|
|
1313
|
-
messageId: normalized.messageId,
|
|
1314
|
-
senderId,
|
|
1315
|
-
senderLabel,
|
|
1316
|
-
wasMentioned: mentionDecision.effectiveWasMentioned,
|
|
1317
|
-
wasReplyToSelf,
|
|
1318
|
-
});
|
|
1319
|
-
return;
|
|
1320
|
-
}
|
|
1321
|
-
if (!(0, helpers_1.isSenderAllowed)({
|
|
1322
|
-
allowFrom: directAllowFrom,
|
|
1323
|
-
senderId,
|
|
1324
|
-
senderUsername: normalized.senderUsername,
|
|
1325
|
-
})) {
|
|
1326
|
-
log?.info?.("clawgram direct allowFrom mismatch", {
|
|
1327
|
-
accountId,
|
|
1328
|
-
senderId,
|
|
1329
|
-
senderUsername: normalized.senderUsername,
|
|
1330
|
-
allowFrom: directAllowFrom,
|
|
1331
|
-
});
|
|
1332
|
-
return;
|
|
1333
|
-
}
|
|
1334
|
-
const access = await (0, direct_dm_1.resolveInboundDirectDmAccessWithRuntime)({
|
|
1335
|
-
cfg,
|
|
1336
|
-
channel: "clawgram",
|
|
1337
|
-
accountId,
|
|
1338
|
-
dmPolicy,
|
|
1339
|
-
allowFrom: directAllowFrom,
|
|
1340
|
-
senderId,
|
|
1341
|
-
rawBody: text,
|
|
1342
|
-
runtime: channelRuntime.commands,
|
|
1343
|
-
isSenderAllowed: (_candidateSenderId, allowEntries) => (0, helpers_1.isSenderAllowed)({
|
|
1344
|
-
allowFrom: allowEntries,
|
|
1345
|
-
senderId,
|
|
1346
|
-
senderUsername,
|
|
1347
|
-
}),
|
|
1348
|
-
readStoreAllowFrom: pairing.readStoreForDmPolicy,
|
|
1349
|
-
});
|
|
1350
|
-
if (access.access.decision === "block") {
|
|
1351
|
-
log?.info?.("clawgram blocking inbound direct message", {
|
|
1352
|
-
accountId,
|
|
1353
|
-
chatId: normalized.chatId,
|
|
1354
|
-
messageId: normalized.messageId,
|
|
1355
|
-
senderId,
|
|
1356
|
-
reason: access.access.reason,
|
|
1357
|
-
reasonCode: access.access.reasonCode,
|
|
1358
|
-
});
|
|
1359
|
-
return;
|
|
1360
|
-
}
|
|
1361
|
-
if (access.access.decision === "pairing") {
|
|
1362
|
-
await pairing.issueChallenge({
|
|
1363
|
-
senderId,
|
|
1364
|
-
senderIdLine: `Your Telegram user id: ${senderId}`,
|
|
1365
|
-
meta: {
|
|
1366
|
-
username: normalized.senderUsername,
|
|
1367
|
-
name: normalized.senderDisplay,
|
|
1368
|
-
},
|
|
1369
|
-
sendPairingReply: async (pairingText) => {
|
|
1370
|
-
await sendTextToConversation({
|
|
1371
|
-
text: pairingText,
|
|
1372
|
-
});
|
|
1373
|
-
},
|
|
1374
|
-
onReplyError: (err) => {
|
|
1375
|
-
log?.info?.("clawgram pairing reply failed", {
|
|
1376
|
-
accountId,
|
|
1377
|
-
chatId: normalized.chatId,
|
|
1378
|
-
senderId,
|
|
1379
|
-
error: String(err),
|
|
1380
|
-
});
|
|
1381
|
-
},
|
|
1382
|
-
});
|
|
1383
|
-
log?.info?.("clawgram pairing required for inbound direct message", {
|
|
1384
|
-
accountId,
|
|
1385
|
-
chatId: normalized.chatId,
|
|
1386
|
-
messageId: normalized.messageId,
|
|
1387
|
-
senderId,
|
|
1388
|
-
});
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1391
|
-
// Same fetch as the group path. In a DM the parent is as often
|
|
1392
|
-
// the agent's own message as the person's — the owner answers a
|
|
1393
|
-
// notice she sent — and neither text is available any other way.
|
|
1394
|
-
const replyParent = await (0, helpers_1.resolveReplyParent)(rawMessage, { selfId, selfLabel });
|
|
1395
|
-
await gram.withTyping(conversationTarget, async () => {
|
|
1396
|
-
await (0, direct_dm_1.dispatchInboundDirectDmWithRuntime)({
|
|
1397
|
-
cfg,
|
|
1398
|
-
runtime: { channel: channelRuntime },
|
|
1399
|
-
channel: "clawgram",
|
|
1400
|
-
channelLabel: "Telegram",
|
|
1401
|
-
accountId,
|
|
1402
|
-
peer: {
|
|
1403
|
-
kind: "direct",
|
|
1404
|
-
id: senderId,
|
|
1405
|
-
},
|
|
1406
|
-
senderId,
|
|
1407
|
-
senderAddress: `telegram:${senderId}`,
|
|
1408
|
-
recipientAddress: selfId ? `telegram:${selfId}` : `telegram:${accountId}`,
|
|
1409
|
-
conversationLabel: senderLabel,
|
|
1410
|
-
rawBody: text,
|
|
1411
|
-
messageId: normalized.messageId,
|
|
1412
|
-
timestamp: normalized.timestamp,
|
|
1413
|
-
commandAuthorized: access.commandAuthorized,
|
|
1414
|
-
provider: "telegram",
|
|
1415
|
-
surface: "clawgram",
|
|
1416
|
-
originatingChannel: "clawgram",
|
|
1417
|
-
originatingTo: senderId,
|
|
1418
|
-
extraContext: {
|
|
1419
|
-
SenderUsername: normalized.senderUsername,
|
|
1420
|
-
SenderName: normalized.senderDisplay,
|
|
1421
|
-
ReplyToId: normalized.replyToMessageId,
|
|
1422
|
-
// Same reason as the group path: highlighted replies happen in
|
|
1423
|
-
// direct messages too, and the fragment is not part of the text.
|
|
1424
|
-
ReplyToQuoteText: normalized.replyQuoteText,
|
|
1425
|
-
ReplyToIsQuote: normalized.replyIsQuote,
|
|
1426
|
-
ReplyToBody: replyParent.body,
|
|
1427
|
-
ReplyToSender: replyParent.sender,
|
|
1428
|
-
NativeChannelId: normalized.chatId,
|
|
1429
|
-
},
|
|
1430
|
-
deliver: async (payload) => {
|
|
1431
|
-
const outboundText = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
1432
|
-
if (!outboundText) {
|
|
1433
|
-
return;
|
|
1434
|
-
}
|
|
1435
|
-
const visibleText = (0, helpers_1.stripSilentReplyToken)(outboundText);
|
|
1436
|
-
if (!visibleText) {
|
|
1437
|
-
log?.info?.("clawgram suppressing silent direct reply", {
|
|
1438
|
-
accountId,
|
|
1439
|
-
chatId: normalized.chatId,
|
|
1440
|
-
messageId: normalized.messageId,
|
|
1441
|
-
});
|
|
1442
|
-
return;
|
|
1443
|
-
}
|
|
1444
|
-
await sendTextToConversation({
|
|
1445
|
-
text: visibleText,
|
|
1446
|
-
replyToMessageId: payload.replyToId ? Number(payload.replyToId) : undefined,
|
|
1447
|
-
});
|
|
1448
|
-
},
|
|
1449
|
-
onRecordError: (err) => {
|
|
1450
|
-
log?.info?.("clawgram failed to record inbound session", {
|
|
1451
|
-
accountId,
|
|
1452
|
-
chatId: normalized.chatId,
|
|
1453
|
-
messageId: normalized.messageId,
|
|
1454
|
-
error: String(err),
|
|
1455
|
-
});
|
|
1456
|
-
},
|
|
1457
|
-
onDispatchError: (err, info) => {
|
|
1458
|
-
log?.info?.("clawgram failed to dispatch reply", {
|
|
1459
|
-
accountId,
|
|
1460
|
-
chatId: normalized.chatId,
|
|
1461
|
-
messageId: normalized.messageId,
|
|
1462
|
-
kind: info.kind,
|
|
1463
|
-
error: String(err),
|
|
1464
|
-
});
|
|
1465
|
-
},
|
|
1466
|
-
});
|
|
1467
|
-
}, {
|
|
1468
|
-
readMessageId: Number(normalized.messageId),
|
|
1469
|
-
});
|
|
1470
|
-
log?.info?.("clawgram inbound handled", {
|
|
1471
|
-
accountId,
|
|
1472
|
-
chatId: normalized.chatId,
|
|
1473
|
-
messageId: normalized.messageId,
|
|
1474
|
-
senderId,
|
|
1475
|
-
senderLabel,
|
|
1476
|
-
});
|
|
1477
|
-
}
|
|
1478
|
-
catch (error) {
|
|
1479
|
-
const rawMessage = event?.message;
|
|
1480
|
-
log?.error?.("clawgram inbound handling failed", {
|
|
1481
|
-
accountId,
|
|
1482
|
-
chatId: String(rawMessage?.chatId ?? rawMessage?.peerId?.userId ?? rawMessage?.peerId?.chatId ?? rawMessage?.peerId?.channelId ?? ""),
|
|
1483
|
-
messageId: String(rawMessage?.id ?? ""),
|
|
1484
|
-
error: String(error),
|
|
1485
|
-
});
|
|
1486
|
-
log?.info?.("clawgram inbound preflight failed", {
|
|
1487
|
-
accountId,
|
|
1488
|
-
chatId: String(rawMessage?.chatId ?? rawMessage?.peerId?.userId ?? rawMessage?.peerId?.chatId ?? rawMessage?.peerId?.channelId ?? ""),
|
|
1489
|
-
messageId: String(rawMessage?.id ?? ""),
|
|
1490
|
-
error: String(error),
|
|
1491
|
-
});
|
|
1492
|
-
}
|
|
1493
|
-
};
|
|
390
|
+
const eventHandler = async (event) => (0, inbound_pipeline_1.handleInboundEvent)(event, {
|
|
391
|
+
accountId, cfg, channelRuntime, client, gram, log, pairing,
|
|
392
|
+
pluginRuntime, runtimes, selfId, selfLabel, selfUsername,
|
|
393
|
+
});
|
|
1494
394
|
client.addEventHandler(eventHandler, eventBuilder);
|
|
1495
395
|
// Being added to a chat arrives as a service message, which `NewMessage`
|
|
1496
396
|
// drops — so joins are observed on the raw update stream instead. Only
|
|
@@ -1539,7 +439,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1539
439
|
},
|
|
1540
440
|
},
|
|
1541
441
|
messaging: {
|
|
1542
|
-
targetPrefixes: [
|
|
442
|
+
targetPrefixes: [constants_1.CHANNEL_ID, "tguserbot", "telegram", "tg"],
|
|
1543
443
|
normalizeTarget(raw) {
|
|
1544
444
|
const normalized = (0, helpers_1.normalizeOutboundTarget)(raw);
|
|
1545
445
|
return normalized || undefined;
|
|
@@ -1611,7 +511,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1611
511
|
return (0, core_1.buildChannelOutboundSessionRoute)({
|
|
1612
512
|
cfg: params.cfg,
|
|
1613
513
|
agentId: params.agentId,
|
|
1614
|
-
channel:
|
|
514
|
+
channel: constants_1.CHANNEL_ID,
|
|
1615
515
|
accountId,
|
|
1616
516
|
peer: {
|
|
1617
517
|
kind: (0, helpers_1.routeKindFromChatType)(chatType),
|
|
@@ -1722,7 +622,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1722
622
|
// resolved once, here, and `ACTION_ALIASES` is the only place that
|
|
1723
623
|
// decides what a name means. An unknown name stays itself and falls
|
|
1724
624
|
// through to the unsupported-action error, as before.
|
|
1725
|
-
const canonical = canonicalAction(action);
|
|
625
|
+
const canonical = (0, actions_1.canonicalAction)(action);
|
|
1726
626
|
// `read` is what OpenClaw core dispatches (`openclaw message read`,
|
|
1727
627
|
// MCP `messages_read`); `list` resolves to it too.
|
|
1728
628
|
if (canonical === "read") {
|
|
@@ -1837,7 +737,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1837
737
|
const downloaded = await (0, media_1.downloadMessageMediaToFile)({
|
|
1838
738
|
client: fetchGram.getClient(),
|
|
1839
739
|
message: found.message,
|
|
1840
|
-
maxBytes: INBOUND_MEDIA_MAX_BYTES,
|
|
740
|
+
maxBytes: attachments_1.INBOUND_MEDIA_MAX_BYTES,
|
|
1841
741
|
dir: fetchDir,
|
|
1842
742
|
fileNameFor: ({ media, extension }) => (0, fetch_media_1.fetchedMediaFileName)({
|
|
1843
743
|
chatId: fetchChatId,
|
|
@@ -1853,7 +753,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1853
753
|
// large to be worth the transfer. Saying "could not fetch" to all
|
|
1854
754
|
// three is how "she ignored the picture" starts.
|
|
1855
755
|
const described = (0, media_1.describeMedia)(found.message?.media);
|
|
1856
|
-
const tooLarge = typeof described?.size === "number" && described.size > INBOUND_MEDIA_MAX_BYTES;
|
|
756
|
+
const tooLarge = typeof described?.size === "number" && described.size > attachments_1.INBOUND_MEDIA_MAX_BYTES;
|
|
1857
757
|
const error = !described
|
|
1858
758
|
? "no-media"
|
|
1859
759
|
: tooLarge
|
|
@@ -1879,7 +779,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1879
779
|
let readError;
|
|
1880
780
|
if (fetchParams.mode !== "file") {
|
|
1881
781
|
try {
|
|
1882
|
-
read = await understandAttachmentFile({
|
|
782
|
+
read = await (0, attachments_1.understandAttachmentFile)({
|
|
1883
783
|
runtime: pluginRuntime,
|
|
1884
784
|
cfg,
|
|
1885
785
|
filePath: downloaded.path,
|
|
@@ -1933,43 +833,80 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1933
833
|
readError,
|
|
1934
834
|
});
|
|
1935
835
|
}
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
836
|
+
/**
|
|
837
|
+
* The scaffold every chat-shaped read shares.
|
|
838
|
+
*
|
|
839
|
+
* `participants`, `topics`, `dialogs`, `joins` and `chatInfo` each
|
|
840
|
+
* spelled out the same sequence: parse, resolve the account, check a
|
|
841
|
+
* scope, fetch the runtime, call it, log counts, answer. Roughly
|
|
842
|
+
* forty lines apiece, differing in four places — which is how a new
|
|
843
|
+
* action came to cost sixty lines of scaffold and how the two gates
|
|
844
|
+
* drifted apart (finding A6-11).
|
|
845
|
+
*
|
|
846
|
+
* The gate follows from the shape rather than being restated: an
|
|
847
|
+
* action that names a chat is gated by `readChats`, `dialogs` has its
|
|
848
|
+
* own discovery gate precisely because its point is to find chats
|
|
849
|
+
* that are not in scope yet, and `joins` has none — the journal only
|
|
850
|
+
* ever holds chats this account was put into.
|
|
851
|
+
*
|
|
852
|
+
* The runtime is a getter, not a value: `joins` reads a file and must
|
|
853
|
+
* not fail merely because no runtime is connected.
|
|
854
|
+
*/
|
|
855
|
+
const runRead = async (spec) => {
|
|
856
|
+
const parsed = spec.parse();
|
|
857
|
+
const readAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
858
|
+
if (!readAccountId) {
|
|
1943
859
|
throw new Error("clawgram: no configured account found");
|
|
1944
860
|
}
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
861
|
+
const target = spec.target?.(parsed);
|
|
862
|
+
if (target !== undefined) {
|
|
863
|
+
if (!(0, history_1.isChatReadable)(target, resolveAccountReadChats(cfg, readAccountId))) {
|
|
864
|
+
actionLog.warn(`clawgram ${spec.name} refused: chat outside read scope`, {
|
|
865
|
+
accountId: readAccountId,
|
|
866
|
+
target,
|
|
867
|
+
});
|
|
868
|
+
throw new Error(`clawgram: not-allowed-chat ${target}`);
|
|
869
|
+
}
|
|
1951
870
|
}
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
871
|
+
else if (spec.discovery) {
|
|
872
|
+
if (!(0, dialogs_1.isChatDiscoveryEnabled)(resolveAccountDiscoverChats(cfg, readAccountId))) {
|
|
873
|
+
actionLog.warn(`clawgram ${spec.name} refused: chat-discovery is not enabled`, {
|
|
874
|
+
accountId: readAccountId,
|
|
875
|
+
});
|
|
876
|
+
throw new Error("clawgram: chat-discovery is not enabled");
|
|
877
|
+
}
|
|
1955
878
|
}
|
|
1956
|
-
const
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
target: participantsParams.target,
|
|
1962
|
-
limit: participantsParams.limit,
|
|
1963
|
-
returned: membership.participants.length,
|
|
1964
|
-
truncated: membership.truncated,
|
|
879
|
+
const gram = () => requireRuntimeFor(readAccountId);
|
|
880
|
+
const result = await spec.run({ parsed, accountId: readAccountId, gram });
|
|
881
|
+
actionLog.info(`clawgram handleAction ${spec.name} completed`, {
|
|
882
|
+
accountId: readAccountId,
|
|
883
|
+
...spec.after(parsed, result),
|
|
1965
884
|
});
|
|
1966
|
-
return (0, core_1.jsonResult)({
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
885
|
+
return (0, core_1.jsonResult)({ ok: true, accountId: readAccountId, ...spec.result(parsed, result) });
|
|
886
|
+
};
|
|
887
|
+
// Membership is a read, so the same `readChats` scope that gates history
|
|
888
|
+
// gates it too: this cannot become a way to enumerate chats the account
|
|
889
|
+
// was never allowed to read.
|
|
890
|
+
if (canonical === "participants") {
|
|
891
|
+
return await runRead({
|
|
892
|
+
name: "participants",
|
|
893
|
+
parse: () => (0, history_1.parseListParticipantsParams)(params),
|
|
894
|
+
target: (p) => p.target,
|
|
895
|
+
run: ({ parsed, gram }) => gram().listParticipants(parsed),
|
|
896
|
+
// Counts only. Member ids are personal data and have no business in
|
|
897
|
+
// a log that is read while debugging something else.
|
|
898
|
+
after: (p, m) => ({
|
|
899
|
+
target: p.target,
|
|
900
|
+
limit: p.limit,
|
|
901
|
+
returned: m.participants.length,
|
|
902
|
+
truncated: m.truncated,
|
|
903
|
+
}),
|
|
904
|
+
result: (p, m) => ({
|
|
905
|
+
chatId: m.chatId ?? p.target,
|
|
906
|
+
count: m.participants.length,
|
|
907
|
+
truncated: m.truncated,
|
|
908
|
+
participants: m.participants,
|
|
909
|
+
}),
|
|
1973
910
|
});
|
|
1974
911
|
}
|
|
1975
912
|
// Topic names. A forum chat is addressed by topic id, and until now an
|
|
@@ -1977,133 +914,77 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1977
914
|
// written in yet was unreachable, and one named in words was unfindable.
|
|
1978
915
|
// Titles say what a chat is working on, so the read scope gates them.
|
|
1979
916
|
if (canonical === "topics") {
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
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,
|
|
917
|
+
return await runRead({
|
|
918
|
+
name: "topics",
|
|
919
|
+
parse: () => (0, topics_1.parseTopicsParams)(params),
|
|
920
|
+
target: (p) => p.target,
|
|
921
|
+
run: ({ parsed, gram }) => gram().listTopics(parsed),
|
|
922
|
+
after: (p, f) => ({
|
|
923
|
+
target: p.target,
|
|
924
|
+
limit: p.limit,
|
|
925
|
+
returned: f.topics.length,
|
|
926
|
+
truncated: f.truncated,
|
|
927
|
+
}),
|
|
928
|
+
result: (p, f) => ({
|
|
929
|
+
chatId: f.chatId ?? p.target,
|
|
930
|
+
count: f.topics.length,
|
|
931
|
+
truncated: f.truncated,
|
|
932
|
+
topics: f.topics,
|
|
933
|
+
}),
|
|
2011
934
|
});
|
|
2012
935
|
}
|
|
2013
936
|
// Which chats this account is in. Not gated by `readChats` — the whole
|
|
2014
937
|
// point is to find chats that are not in it yet — so it has a gate of
|
|
2015
938
|
// its own, is metadata only, and never reports direct chats.
|
|
2016
939
|
if (canonical === "dialogs") {
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
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,
|
|
940
|
+
return await runRead({
|
|
941
|
+
name: "dialogs",
|
|
942
|
+
parse: () => (0, dialogs_1.parseDialogsParams)(params),
|
|
943
|
+
discovery: true,
|
|
944
|
+
run: ({ parsed, gram }) => gram().listDialogs(parsed),
|
|
945
|
+
// Counts only: which chats a person's account sits in is exactly
|
|
946
|
+
// the kind of thing that should not be sitting in a log.
|
|
947
|
+
after: (p, f) => ({ limit: p.limit, returned: f.dialogs.length, truncated: f.truncated }),
|
|
948
|
+
result: (_p, f) => ({ count: f.dialogs.length, truncated: f.truncated, dialogs: f.dialogs }),
|
|
2047
949
|
});
|
|
2048
950
|
}
|
|
2049
951
|
// Where this account was recently added, and by whom. Reading the journal
|
|
2050
952
|
// has no scope check of its own: it only ever contains chats this account
|
|
2051
953
|
// was put into, which is exactly what the caller is allowed to learn.
|
|
2052
954
|
if (canonical === "joins") {
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
returned: selected.length,
|
|
2065
|
-
});
|
|
2066
|
-
return (0, core_1.jsonResult)({
|
|
2067
|
-
ok: true,
|
|
2068
|
-
accountId: joinsAccountId,
|
|
2069
|
-
count: selected.length,
|
|
2070
|
-
joins: selected,
|
|
955
|
+
return await runRead({
|
|
956
|
+
name: "joins",
|
|
957
|
+
parse: () => (0, joins_1.parseJoinsParams)(params),
|
|
958
|
+
// No runtime: this reads a file, and must answer with none connected.
|
|
959
|
+
run: async ({ parsed, accountId: joinsAccountId }) => (0, joins_1.selectJoinRecords)((0, joins_1.readJoinRecords)((0, joins_1.resolveJoinsJournalPath)(cfg?.channels?.["clawgram"]?.accounts?.[joinsAccountId], joinsAccountId)), parsed),
|
|
960
|
+
after: (p, selected) => ({
|
|
961
|
+
since: p.since ?? null,
|
|
962
|
+
limit: p.limit,
|
|
963
|
+
returned: selected.length,
|
|
964
|
+
}),
|
|
965
|
+
result: (_p, selected) => ({ count: selected.length, joins: selected }),
|
|
2071
966
|
});
|
|
2072
967
|
}
|
|
2073
968
|
// Describing a chat is a read, so the same `readChats` scope that gates
|
|
2074
969
|
// history gates it too — this must not become a way to learn the title
|
|
2075
970
|
// and size of a chat the account was never allowed to read.
|
|
2076
971
|
if (canonical === "chatInfo") {
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
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 },
|
|
972
|
+
return await runRead({
|
|
973
|
+
name: "chatInfo",
|
|
974
|
+
parse: () => (0, chat_info_1.parseChatInfoParams)(params, toolContext),
|
|
975
|
+
target: (p) => p.target,
|
|
976
|
+
run: async ({ parsed, gram }) => {
|
|
977
|
+
const { entity, full } = await gram().getChatInfo(parsed.target);
|
|
978
|
+
return (0, chat_info_1.describeChat)(entity, full);
|
|
979
|
+
},
|
|
980
|
+
// Type and size only. The title of a private chat is as personal as
|
|
981
|
+
// its contents and has no business in a debugging log.
|
|
982
|
+
after: (_p, info) => ({
|
|
983
|
+
type: info.type,
|
|
984
|
+
memberCount: info.memberCount ?? null,
|
|
985
|
+
isForum: info.isForum ?? null,
|
|
986
|
+
}),
|
|
987
|
+
result: (p, info) => ({ chat: { ...info, chatId: info.chatId ?? p.target } }),
|
|
2107
988
|
});
|
|
2108
989
|
}
|
|
2109
990
|
// A reaction is an outbound act on someone else's message, so it is
|
|
@@ -2164,15 +1045,43 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2164
1045
|
// returns after the gate so a dry run exercises the same refusals a
|
|
2165
1046
|
// real call would hit. People's ids stay out of the logs throughout;
|
|
2166
1047
|
// the JSON result carries them to the caller, the journal does not.
|
|
2167
|
-
const manageAction = MANAGE_ACTIONS.has(canonical) ? canonical : undefined;
|
|
1048
|
+
const manageAction = actions_1.MANAGE_ACTIONS.has(canonical) ? canonical : undefined;
|
|
2168
1049
|
if (manageAction) {
|
|
2169
1050
|
const manageAccountId = resolveRuntimeAccountId(cfg, accountId);
|
|
2170
1051
|
if (!manageAccountId) {
|
|
2171
1052
|
throw new Error("clawgram: no configured account found");
|
|
2172
1053
|
}
|
|
2173
1054
|
const manageScope = resolveAccountManageChats(cfg, manageAccountId);
|
|
2174
|
-
const
|
|
2175
|
-
|
|
1055
|
+
const requireRuntime = () => requireRuntimeFor(manageAccountId);
|
|
1056
|
+
/**
|
|
1057
|
+
* The scaffold every management action shares.
|
|
1058
|
+
*
|
|
1059
|
+
* Six actions used to spell it out one after another: resolve the
|
|
1060
|
+
* account, check the scope, log, answer a dry run, call the
|
|
1061
|
+
* runtime, log again, build the result. A change to any of those —
|
|
1062
|
+
* the dry-run contract, say — was a six-place edit in the plugin's
|
|
1063
|
+
* largest file, and the one deliberate exception (createGroup does
|
|
1064
|
+
* not check a chat scope, because the chat does not exist yet) was
|
|
1065
|
+
* invisible among the copies (finding A12-06).
|
|
1066
|
+
*
|
|
1067
|
+
* The differences stay written at each call site: what to parse,
|
|
1068
|
+
* what to log, what to run, what to answer. Only the scaffold moved.
|
|
1069
|
+
*/
|
|
1070
|
+
const runManage = async (spec) => {
|
|
1071
|
+
const parsed = spec.parse();
|
|
1072
|
+
const target = spec.target(parsed);
|
|
1073
|
+
if (target === undefined) {
|
|
1074
|
+
// Nothing to check a scope against yet, so the gate is coarser:
|
|
1075
|
+
// management must be enabled at all for this account.
|
|
1076
|
+
if (!(0, manage_1.isManagementEnabled)(manageScope)) {
|
|
1077
|
+
actionLog.warn(`clawgram ${spec.name} refused: management is not enabled`, {
|
|
1078
|
+
accountId: manageAccountId,
|
|
1079
|
+
});
|
|
1080
|
+
throw new Error("clawgram: chat management is not enabled for this account — "
|
|
1081
|
+
+ `set channels.clawgram.accounts.${manageAccountId}.manageChats`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
else if (!(0, manage_1.isChatManageable)(target, manageScope)) {
|
|
2176
1085
|
actionLog.warn("clawgram management refused: chat outside manage scope", {
|
|
2177
1086
|
accountId: manageAccountId,
|
|
2178
1087
|
action: manageAction,
|
|
@@ -2180,188 +1089,126 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2180
1089
|
});
|
|
2181
1090
|
throw new Error(`clawgram: not-managed-chat ${target}`);
|
|
2182
1091
|
}
|
|
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", {
|
|
1092
|
+
actionLog.info(`clawgram handleAction ${spec.name}`, {
|
|
2203
1093
|
accountId: manageAccountId,
|
|
2204
1094
|
dryRun: dryRun === true,
|
|
2205
|
-
|
|
2206
|
-
hasAbout: Boolean(createParams.about),
|
|
1095
|
+
...spec.before(parsed),
|
|
2207
1096
|
});
|
|
2208
1097
|
if (dryRun === true) {
|
|
2209
|
-
return (0, core_1.jsonResult)({
|
|
1098
|
+
return (0, core_1.jsonResult)({
|
|
1099
|
+
ok: true,
|
|
1100
|
+
dryRun: true,
|
|
1101
|
+
accountId: manageAccountId,
|
|
1102
|
+
...(target === undefined ? {} : { chatId: target }),
|
|
1103
|
+
});
|
|
2210
1104
|
}
|
|
2211
|
-
const
|
|
2212
|
-
|
|
1105
|
+
const gram = requireRuntime();
|
|
1106
|
+
spec.precondition?.(gram);
|
|
1107
|
+
const result = await spec.run(gram, parsed);
|
|
1108
|
+
actionLog.info(`clawgram handleAction ${spec.name} completed`, {
|
|
2213
1109
|
accountId: manageAccountId,
|
|
2214
|
-
|
|
2215
|
-
missing: created.missing.length,
|
|
1110
|
+
...spec.after(parsed, result),
|
|
2216
1111
|
});
|
|
2217
|
-
return (0, core_1.jsonResult)({
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
1112
|
+
return (0, core_1.jsonResult)({ ok: true, accountId: manageAccountId, ...spec.result(parsed, result) });
|
|
1113
|
+
};
|
|
1114
|
+
if (manageAction === "createGroup") {
|
|
1115
|
+
return await runManage({
|
|
1116
|
+
name: "createGroup",
|
|
1117
|
+
parse: () => (0, manage_1.parseCreateGroupParams)(params),
|
|
1118
|
+
// A group being created is not in any scope yet.
|
|
1119
|
+
target: () => undefined,
|
|
1120
|
+
before: (p) => ({ users: p.users.length, hasAbout: Boolean(p.about) }),
|
|
1121
|
+
run: (gram, p) => gram.createGroup(p),
|
|
1122
|
+
after: (_p, created) => ({ chatId: created.chatId ?? null, missing: created.missing.length }),
|
|
1123
|
+
result: (_p, created) => ({ chatId: created.chatId, missing: created.missing }),
|
|
2222
1124
|
});
|
|
2223
1125
|
}
|
|
2224
1126
|
if (manageAction === "addMembers") {
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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,
|
|
1127
|
+
return await runManage({
|
|
1128
|
+
name: "addMembers",
|
|
1129
|
+
parse: () => (0, manage_1.parseAddMembersParams)(params, toolContext),
|
|
1130
|
+
target: (p) => p.target,
|
|
1131
|
+
before: (p) => ({ target: p.target, users: p.users.length }),
|
|
1132
|
+
run: (gram, p) => gram.addChatMembers(p),
|
|
1133
|
+
after: (p, added) => ({
|
|
1134
|
+
target: p.target,
|
|
1135
|
+
requested: p.users.length,
|
|
1136
|
+
missing: added.missing.length,
|
|
1137
|
+
}),
|
|
1138
|
+
result: (p, added) => ({
|
|
1139
|
+
chatId: added.chatId ?? p.target,
|
|
1140
|
+
requested: p.users.length,
|
|
1141
|
+
// Telegram refuses silently-restricted invites per user; the
|
|
1142
|
+
// caller gets the ids so it can hand them an invite link.
|
|
1143
|
+
missing: added.missing,
|
|
1144
|
+
}),
|
|
2251
1145
|
});
|
|
2252
1146
|
}
|
|
2253
1147
|
if (manageAction === "removeMember") {
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
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,
|
|
1148
|
+
return await runManage({
|
|
1149
|
+
name: "removeMember",
|
|
1150
|
+
parse: () => (0, manage_1.parseRemoveMemberParams)(params, toolContext),
|
|
1151
|
+
target: (p) => p.target,
|
|
1152
|
+
before: (p) => ({ target: p.target, ban: p.ban }),
|
|
1153
|
+
run: (gram, p) => gram.removeChatMember(p),
|
|
1154
|
+
after: (p) => ({ target: p.target, ban: p.ban }),
|
|
1155
|
+
result: (p) => ({ chatId: p.target, user: p.user, banned: p.ban }),
|
|
2277
1156
|
});
|
|
2278
1157
|
}
|
|
2279
1158
|
if (manageAction === "promoteAdmin" || manageAction === "demoteAdmin") {
|
|
2280
|
-
const
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
target:
|
|
2288
|
-
isAdmin:
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
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 } : {}),
|
|
1159
|
+
const promote = manageAction === "promoteAdmin";
|
|
1160
|
+
return await runManage({
|
|
1161
|
+
// Both spellings log as `setAdmin`, as they always have.
|
|
1162
|
+
name: "setAdmin",
|
|
1163
|
+
parse: () => (promote
|
|
1164
|
+
? (0, manage_1.parsePromoteAdminParams)(params, toolContext)
|
|
1165
|
+
: (0, manage_1.parseDemoteAdminParams)(params, toolContext)),
|
|
1166
|
+
target: (p) => p.target,
|
|
1167
|
+
before: (p) => ({ target: p.target, isAdmin: p.isAdmin, hasRank: Boolean(p.rank) }),
|
|
1168
|
+
run: (gram, p) => gram.setChatAdmin(p),
|
|
1169
|
+
after: (p) => ({ target: p.target, isAdmin: p.isAdmin }),
|
|
1170
|
+
result: (p) => ({
|
|
1171
|
+
chatId: p.target,
|
|
1172
|
+
user: p.user,
|
|
1173
|
+
isAdmin: p.isAdmin,
|
|
1174
|
+
...(p.rank ? { rank: p.rank } : {}),
|
|
1175
|
+
}),
|
|
2307
1176
|
});
|
|
2308
1177
|
}
|
|
2309
1178
|
if (manageAction === "transferOwnership") {
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
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,
|
|
1179
|
+
return await runManage({
|
|
1180
|
+
name: "transferOwnership",
|
|
1181
|
+
parse: () => (0, manage_1.parseTransferOwnershipParams)(params, toolContext),
|
|
1182
|
+
target: (p) => p.target,
|
|
1183
|
+
before: (p) => ({ target: p.target }),
|
|
1184
|
+
// The password stays inside the runtime: it is read from the
|
|
1185
|
+
// account config at start-up and never travels through dispatch
|
|
1186
|
+
// arguments, which are one log call away from the journal.
|
|
1187
|
+
precondition: (gram) => {
|
|
1188
|
+
if (!gram.twoFaPassword) {
|
|
1189
|
+
throw new Error("clawgram: ownership transfer requires twoFaPassword in the account config "
|
|
1190
|
+
+ "(the account's Telegram 2FA password, as a literal or a SecretRef)");
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
run: (gram, p) => gram.transferChatOwnership(p),
|
|
1194
|
+
after: (p) => ({ target: p.target }),
|
|
1195
|
+
result: (p) => ({ chatId: p.target, newOwner: p.user }),
|
|
2338
1196
|
});
|
|
2339
1197
|
}
|
|
2340
1198
|
// inviteLink — the only management action left.
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
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,
|
|
1199
|
+
return await runManage({
|
|
1200
|
+
name: "inviteLink",
|
|
1201
|
+
parse: () => (0, manage_1.parseInviteLinkParams)(params, toolContext),
|
|
1202
|
+
target: (p) => p.target,
|
|
1203
|
+
before: (p) => ({
|
|
1204
|
+
target: p.target,
|
|
1205
|
+
hasExpiry: p.expireDate !== undefined,
|
|
1206
|
+
usageLimit: p.usageLimit ?? null,
|
|
1207
|
+
requestNeeded: p.requestNeeded,
|
|
1208
|
+
}),
|
|
1209
|
+
run: (gram, p) => gram.exportChatInviteLink(p),
|
|
1210
|
+
after: (p, exported) => ({ target: p.target, hasLink: Boolean(exported.link) }),
|
|
1211
|
+
result: (p, exported) => ({ chatId: p.target, link: exported.link }),
|
|
2365
1212
|
});
|
|
2366
1213
|
}
|
|
2367
1214
|
// Core normalizes whichever of these it filled in to a local path (see
|
|
@@ -2432,7 +1279,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2432
1279
|
// the same prose as a message and renders identically.
|
|
2433
1280
|
parseMode: (0, helpers_1.resolveOutboundParseMode)(params, cfg, uploadAccountId),
|
|
2434
1281
|
replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(rawUploadTo, uploadReplyToId),
|
|
2435
|
-
messageThreadId: parseOptionalThreadId(uploadThreadId),
|
|
1282
|
+
messageThreadId: (0, helpers_1.parseOptionalThreadId)(uploadThreadId),
|
|
2436
1283
|
asVoice,
|
|
2437
1284
|
});
|
|
2438
1285
|
actionLog.info("clawgram handleAction upload-file completed", {
|
|
@@ -2455,7 +1302,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2455
1302
|
const to = (0, helpers_1.normalizeOutboundTarget)(rawTo);
|
|
2456
1303
|
const replyToId = (0, param_readers_1.readStringOrNumberParam)(params, "replyToId") ?? (0, param_readers_1.readStringOrNumberParam)(params, "replyTo");
|
|
2457
1304
|
const threadId = (0, param_readers_1.readStringOrNumberParam)(params, "threadId");
|
|
2458
|
-
const messageThreadId = parseOptionalThreadId(threadId);
|
|
1305
|
+
const messageThreadId = (0, helpers_1.parseOptionalThreadId)(threadId);
|
|
2459
1306
|
// Omitting parseMode inherits the account's configured mode rather
|
|
2460
1307
|
// than falling back to plain text (2.13.0): an account set to `html`
|
|
2461
1308
|
// used to render replies as HTML and these sends as raw markup.
|
|
@@ -2607,245 +1454,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
2607
1454
|
});
|
|
2608
1455
|
},
|
|
2609
1456
|
},
|
|
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
|
-
},
|
|
1457
|
+
outbound: (0, outbound_1.createOutbound)(runtimes),
|
|
2849
1458
|
};
|
|
2850
1459
|
};
|
|
2851
1460
|
exports.createChannelPlugin = createChannelPlugin;
|