clawgram 2.23.0 → 2.25.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.
@@ -0,0 +1,937 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleInboundEvent = handleInboundEvent;
4
+ // Входящий контур: одно событие Telegram от нормализации до ответа.
5
+ //
6
+ // Вынесено из `channel.ts` — 856 строк внутри `gateway.startAccount`, самый
7
+ // крупный кусок файла и единственный путь, по которому проходит каждое
8
+ // входящее сообщение (находка A6-11).
9
+ //
10
+ // Почему это оказалось возможно сделать безопасно. Свободных переменных у
11
+ // обработчика ровно девять, и их назвал не я, а компилятор: тело временно
12
+ // вынесли функцией без параметров и прочитали список «Cannot find name».
13
+ // Остальные два десятка имён — импорты модуля, они переехали сюда сами.
14
+ //
15
+ // Тело перенесено ДОСЛОВНО. Контекст разбирается первой строкой, чтобы
16
+ // каждая следующая осталась той же самой: это проверяется сравнением с
17
+ // исходным блоком, а не глазами.
18
+ const core_1 = require("openclaw/plugin-sdk/core");
19
+ const direct_dm_1 = require("openclaw/plugin-sdk/direct-dm");
20
+ const channel_inbound_1 = require("openclaw/plugin-sdk/channel-inbound");
21
+ const channel_reply_pipeline_1 = require("openclaw/plugin-sdk/channel-reply-pipeline");
22
+ const inbound_envelope_1 = require("openclaw/plugin-sdk/inbound-envelope");
23
+ const inbound_reply_dispatch_1 = require("openclaw/plugin-sdk/inbound-reply-dispatch");
24
+ const constants_1 = require("./constants");
25
+ const normalize_1 = require("./normalize");
26
+ const reactions_1 = require("./reactions");
27
+ const silent_reaction_1 = require("./silent-reaction");
28
+ const system_notice_1 = require("./system-notice");
29
+ const group_reply_address_1 = require("./group-reply-address");
30
+ const helpers_1 = require("./helpers");
31
+ const constants_2 = require("./constants");
32
+ const attachments_1 = require("./attachments");
33
+ /**
34
+ * Wires `reactToSilentMention` to this account's runtime, config and log.
35
+ *
36
+ * The decision itself lives in `silent-reaction.ts`, testable without a
37
+ * Telegram connection; everything here is lookup. Missing pieces — no
38
+ * connected client, no model access — resolve to no reaction rather than to
39
+ * an error, because by this point the agent has already declined to reply.
40
+ */
41
+ async function reactToSilentMentionForAccount(params) {
42
+ const gram = params.gram;
43
+ const llm = params.pluginRuntime?.llm;
44
+ if (!gram || typeof llm?.complete !== "function") {
45
+ return;
46
+ }
47
+ await (0, silent_reaction_1.reactToSilentMention)({
48
+ appetite: (0, reactions_1.resolveAgentReactionGuidance)((0, helpers_1.readAccountReactionLevel)(params.cfg, params.accountId)),
49
+ model: (0, helpers_1.readAccountReactionModel)(params.cfg, params.accountId),
50
+ wasMentioned: params.wasMentioned,
51
+ chatId: params.chatId,
52
+ messageId: params.messageId,
53
+ messageText: params.messageText,
54
+ deps: {
55
+ // Bound rather than destructured: the SDK may implement this as a
56
+ // method that needs its receiver.
57
+ complete: (args) => llm.complete(args),
58
+ sendReaction: (args) => gram.sendReaction(args),
59
+ allowedReactions: gram.getAllowedReactions
60
+ ? () => gram.getAllowedReactions(params.chatId)
61
+ : undefined,
62
+ onDecision: (info) => actionLog.info("clawgram silent-mention reaction", {
63
+ accountId: params.accountId,
64
+ ...info,
65
+ }),
66
+ },
67
+ });
68
+ }
69
+ const actionLog = (0, core_1.createSubsystemLogger)("channels/clawgram");
70
+ async function handleInboundEvent(event, ctx) {
71
+ const { accountId, cfg, channelRuntime, client, gram, log, pairing, pluginRuntime, runtimes, selfId, selfLabel, selfUsername } = ctx;
72
+ try {
73
+ const rawMessage = event?.message;
74
+ const rawPeerUserId = rawMessage?.peerId?.userId;
75
+ const rawPeerChatId = rawMessage?.peerId?.chatId;
76
+ const rawPeerChannelId = rawMessage?.peerId?.channelId;
77
+ const directLike = rawPeerUserId !== undefined ||
78
+ (typeof rawMessage?.chatId === "number" && rawMessage.chatId > 0);
79
+ if (directLike) {
80
+ log?.info?.("clawgram raw direct-like event", {
81
+ accountId,
82
+ messageId: String(rawMessage?.id ?? ""),
83
+ chatId: String(rawMessage?.chatId ?? ""),
84
+ peerUserId: String(rawPeerUserId ?? ""),
85
+ peerChatId: String(rawPeerChatId ?? ""),
86
+ peerChannelId: String(rawPeerChannelId ?? ""),
87
+ senderId: String(rawMessage?.senderId ?? rawMessage?.fromId?.userId ?? ""),
88
+ out: rawMessage?.out === true,
89
+ textLength: typeof rawMessage?.message === "string" ? rawMessage.message.length : typeof rawMessage?.text === "string" ? rawMessage.text.length : 0,
90
+ });
91
+ }
92
+ const normalized = (0, normalize_1.normalizeTelegramEvent)(event, accountId);
93
+ if (!normalized) {
94
+ if (directLike) {
95
+ log?.info?.("clawgram normalize returned null", {
96
+ accountId,
97
+ messageId: String(rawMessage?.id ?? ""),
98
+ chatId: String(rawMessage?.chatId ?? ""),
99
+ peerUserId: String(rawPeerUserId ?? ""),
100
+ });
101
+ }
102
+ return;
103
+ }
104
+ if (normalized.isOutgoing) {
105
+ if (normalized.chatType === "direct") {
106
+ log?.info?.("clawgram skipping outgoing direct event", {
107
+ accountId,
108
+ chatId: normalized.chatId,
109
+ messageId: normalized.messageId,
110
+ senderId: normalized.senderId,
111
+ });
112
+ }
113
+ return;
114
+ }
115
+ if (normalized.chatType === "channel") {
116
+ log?.info?.("clawgram skipping channel inbound", {
117
+ accountId,
118
+ chatId: normalized.chatId,
119
+ chatType: normalized.chatType,
120
+ messageId: normalized.messageId,
121
+ });
122
+ return;
123
+ }
124
+ // Ворота — ДО сети. До 2.25.0 на каждое сообщение из любой группы,
125
+ // где сидит аккаунт, — включая группы вне `groups` — плагин делал
126
+ // до семи запросов к Telegram (профиль отправителя, адрес ответа,
127
+ // цель чата) и только потом отбрасывал сообщение как чужое.
128
+ // Посторонний, флудящий в такой группе, тратил соединение и
129
+ // rate-limit аккаунта (B5-04, остаток A5-06). Группа вне конфига
130
+ // и выключенная группа заканчиваются здесь, без единого вызова.
131
+ const earlyScopes = (0, helpers_1.resolveAccountScopes)(cfg, accountId);
132
+ const earlyGroupConfig = normalized.chatType === "group"
133
+ ? (0, helpers_1.resolveGroupConfig)(earlyScopes.groups, normalized.chatId)
134
+ : undefined;
135
+ if (normalized.chatType === "group") {
136
+ if (!earlyGroupConfig) {
137
+ log?.info?.("clawgram skipping group not present in groups config", {
138
+ accountId,
139
+ chatId: normalized.chatId,
140
+ messageId: normalized.messageId,
141
+ });
142
+ return;
143
+ }
144
+ if (earlyGroupConfig.enabled === false) {
145
+ log?.info?.("clawgram skipping disabled group", {
146
+ accountId,
147
+ chatId: normalized.chatId,
148
+ messageId: normalized.messageId,
149
+ });
150
+ return;
151
+ }
152
+ }
153
+ // Профиль отправителя нужен воротам только когда allowFrom
154
+ // называет кого-то по @handle, а сообщение handle не принесло;
155
+ // числовые id сверяются без сети.
156
+ const gateAllowFrom = normalized.chatType === "group"
157
+ ? earlyGroupConfig?.allowFrom
158
+ : earlyScopes.allowFrom;
159
+ const allowFromNeedsHandle = Array.isArray(gateAllowFrom)
160
+ && gateAllowFrom.some((entry) => String(entry).trim().startsWith("@"));
161
+ const needsProfile = !normalized.senderUsername && allowFromNeedsHandle;
162
+ const senderProfile = needsProfile
163
+ ? (normalized.chatType === "direct"
164
+ ? await (0, helpers_1.resolveSenderProfileWithTimeout)(rawMessage, {
165
+ senderId: normalized.senderId,
166
+ client,
167
+ }, 1500)
168
+ : await (0, helpers_1.resolveSenderProfile)(rawMessage, {
169
+ senderId: normalized.senderId,
170
+ client,
171
+ }))
172
+ : {};
173
+ if (!normalized.senderUsername && senderProfile.username) {
174
+ normalized.senderUsername = senderProfile.username;
175
+ }
176
+ if (!normalized.senderDisplay && senderProfile.display) {
177
+ normalized.senderDisplay = senderProfile.display;
178
+ }
179
+ let text = normalized.text?.trim();
180
+ // Whether this sender may reach the agent at all — decided before
181
+ // the attachment is fetched.
182
+ //
183
+ // Reading an attachment downloads up to 25 MB and then spends a
184
+ // transcription or vision call on it. That used to happen for
185
+ // every photo and voice note from anyone in any group the account
186
+ // sits in, and only afterwards was the sender checked against
187
+ // `allowFrom`. A stranger could therefore spend the owner's model
188
+ // budget at will. None of these checks depend on the message text,
189
+ // so they cost nothing to run first.
190
+ const inboundSenderId = normalized.senderId ?? normalized.chatId;
191
+ const inboundScopes = (0, helpers_1.resolveAccountScopes)(cfg, accountId);
192
+ const inboundGroupConfig = normalized.chatType === "group"
193
+ ? (0, helpers_1.resolveGroupConfig)(inboundScopes.groups, normalized.chatId)
194
+ : undefined;
195
+ const senderMayReachAgent = normalized.chatType === "group"
196
+ ? Boolean(inboundGroupConfig
197
+ && inboundGroupConfig.enabled !== false
198
+ && (0, helpers_1.isSenderAllowed)({
199
+ allowFrom: inboundGroupConfig.allowFrom,
200
+ senderId: inboundSenderId,
201
+ senderUsername: normalized.senderUsername,
202
+ }))
203
+ : (0, helpers_1.isSenderAllowed)({
204
+ allowFrom: inboundScopes.allowFrom,
205
+ senderId: inboundSenderId,
206
+ senderUsername: normalized.senderUsername,
207
+ });
208
+ // An attachment carries no text of its own, and dropping it as
209
+ // "empty" is how the assistant used to go silent on being spoken
210
+ // to or shown something. Read it into the body instead: for a
211
+ // voice note and a screenshot alike, the attachment *is* the
212
+ // message. A caption is kept and the reading appended, because
213
+ // "look at this" plus the picture is one thought, not two.
214
+ // Адрес ответа и цель чата — сеть, и нужны только тому, кому
215
+ // отвечают: считаются после ворот (B5-04).
216
+ const directReplyTarget = normalized.chatType === "direct" ? undefined
217
+ : senderMayReachAgent ? await (0, helpers_1.resolveReplyTarget)(rawMessage) : undefined;
218
+ const replyTarget = normalized.chatType === "direct"
219
+ ? normalized.chatId
220
+ : senderMayReachAgent ? await (0, helpers_1.resolveChatTarget)(rawMessage) : undefined;
221
+ if (replyTarget) {
222
+ normalized.replyTarget = replyTarget;
223
+ }
224
+ const attachment = senderMayReachAgent ? await (0, attachments_1.readInboundAttachment)({
225
+ gram,
226
+ event,
227
+ cfg,
228
+ runtime: pluginRuntime,
229
+ log,
230
+ accountId,
231
+ chatId: normalized.chatId,
232
+ messageId: normalized.messageId,
233
+ }) : undefined;
234
+ if (attachment) {
235
+ const marker = attachment.understanding === "transcript" ? "голосовое" : "изображение";
236
+ const read = `[${marker}] ${attachment.text}`;
237
+ text = text ? `${text}\n\n${read}` : read;
238
+ }
239
+ // What the mention gate is allowed to read.
240
+ //
241
+ // A transcript is the sender's own speech, so "Тина, посмотри"
242
+ // said aloud addresses the agent exactly as typing it would. A
243
+ // description is not: it is a vision model reading somebody
244
+ // else's content, and a screenshot of a chat where a third party
245
+ // wrote "@tina_bot" is not an address to her. Feeding the whole
246
+ // body to the gate made every such screenshot wake her up.
247
+ const addressableText = (0, helpers_1.resolveAddressableText)({
248
+ messageText: normalized.text,
249
+ bodyText: text,
250
+ understanding: attachment?.understanding,
251
+ });
252
+ if (!text) {
253
+ log?.info?.("clawgram skipping empty inbound text", {
254
+ accountId,
255
+ chatId: normalized.chatId,
256
+ messageId: normalized.messageId,
257
+ });
258
+ return;
259
+ }
260
+ const senderId = normalized.senderId ?? normalized.chatId;
261
+ const isTelegramServiceDirect = normalized.chatType === "direct" &&
262
+ (normalized.chatId === constants_1.TELEGRAM_SERVICE_CHAT_ID || senderId === constants_1.TELEGRAM_SERVICE_CHAT_ID);
263
+ const isSavedMessagesDirect = normalized.chatType === "direct" &&
264
+ Boolean(selfId) &&
265
+ normalized.chatId === selfId &&
266
+ senderId === selfId;
267
+ if (isTelegramServiceDirect) {
268
+ log?.info?.("clawgram skipping Telegram service direct chat", {
269
+ accountId,
270
+ chatId: normalized.chatId,
271
+ messageId: normalized.messageId,
272
+ senderId,
273
+ });
274
+ return;
275
+ }
276
+ if (isSavedMessagesDirect) {
277
+ log?.info?.("clawgram skipping Saved Messages direct chat", {
278
+ accountId,
279
+ chatId: normalized.chatId,
280
+ messageId: normalized.messageId,
281
+ senderId,
282
+ selfId,
283
+ });
284
+ return;
285
+ }
286
+ const senderUsername = normalized.senderUsername;
287
+ const senderLabel = normalized.senderDisplay || normalized.senderUsername || senderId;
288
+ const conversationTarget = normalized.chatType === "direct"
289
+ ? normalized.chatId
290
+ : normalized.replyTarget ?? normalized.chatId;
291
+ const conversationFallbackTargets = [
292
+ normalized.chatType === "direct" ? directReplyTarget : undefined,
293
+ normalized.chatType === "direct" ? normalized.replyTarget : undefined,
294
+ normalized.chatType === "direct" && normalized.senderUsername ? `@${normalized.senderUsername}` : undefined,
295
+ normalized.chatId,
296
+ ].filter((target, index, items) => {
297
+ if (!target || target === conversationTarget) {
298
+ return false;
299
+ }
300
+ return items.findIndex((candidate) => candidate === target) === index;
301
+ });
302
+ const sendTextToConversation = async (args) => {
303
+ const targets = [conversationTarget, ...conversationFallbackTargets];
304
+ // Replies have no per-call parseMode slot — the format is an
305
+ // account setting (2.3.1); absent keeps the GramJS default
306
+ // (its markdown parser — not plain text, see 2.15.0 notes).
307
+ const replyParseMode = gram.replyParseMode;
308
+ let lastError;
309
+ for (const target of targets) {
310
+ try {
311
+ return await gram.sendText({
312
+ target,
313
+ text: args.text,
314
+ replyToMessageId: args.replyToMessageId,
315
+ messageThreadId: args.messageThreadId,
316
+ parseMode: replyParseMode,
317
+ });
318
+ }
319
+ catch (error) {
320
+ lastError = error;
321
+ }
322
+ }
323
+ throw lastError;
324
+ };
325
+ // Resolved once, above, before the attachment fetch that depends on
326
+ // the answer — and by the same resolver `resolveAccount` uses, so the
327
+ // gate applied here is the one the account was started with.
328
+ const { allowFrom: directAllowFrom } = inboundScopes;
329
+ const dmPolicy = "open";
330
+ if (normalized.chatType === "group") {
331
+ const groupConfig = inboundGroupConfig;
332
+ if (!groupConfig) {
333
+ log?.info?.("clawgram skipping group not present in groups config", {
334
+ accountId,
335
+ chatId: normalized.chatId,
336
+ messageId: normalized.messageId,
337
+ });
338
+ return;
339
+ }
340
+ if (groupConfig.enabled === false) {
341
+ log?.info?.("clawgram skipping disabled group", {
342
+ accountId,
343
+ chatId: normalized.chatId,
344
+ messageId: normalized.messageId,
345
+ });
346
+ return;
347
+ }
348
+ if (!(0, helpers_1.isSenderAllowed)({
349
+ allowFrom: groupConfig.allowFrom,
350
+ senderId,
351
+ senderUsername: normalized.senderUsername,
352
+ })) {
353
+ log?.info?.("clawgram blocking inbound group sender by allowFrom", {
354
+ accountId,
355
+ chatId: normalized.chatId,
356
+ messageId: normalized.messageId,
357
+ senderId,
358
+ username: normalized.senderUsername,
359
+ // Сам список — id владельца и допущенных — в журнал не идёт:
360
+ // посторонний управлял бы числом его копий в journald (B5-09).
361
+ allowFromCount: Array.isArray(groupConfig.allowFrom) ? groupConfig.allowFrom.length : 0,
362
+ allowFromHasWildcard: Array.isArray(groupConfig.allowFrom) && groupConfig.allowFrom.some((e) => String(e).trim() === "*"),
363
+ });
364
+ return;
365
+ }
366
+ const scopedGroupPeerId = (0, helpers_1.buildScopedGroupPeerId)(accountId, normalized.chatId);
367
+ const { route: inboundRoute, buildEnvelope } = (0, inbound_envelope_1.resolveInboundRouteEnvelopeBuilderWithRuntime)({
368
+ cfg,
369
+ channel: "clawgram",
370
+ accountId,
371
+ peer: {
372
+ kind: "group",
373
+ id: scopedGroupPeerId,
374
+ },
375
+ runtime: channelRuntime,
376
+ sessionStore: cfg?.session?.store,
377
+ });
378
+ // channelRuntime comes from the untyped ctx, so the generic route type falls
379
+ // back to the minimal RouteLike. The runtime value is a ResolvedAgentRoute.
380
+ const route = inboundRoute;
381
+ // Under `tag` the name is not an address: in a chat of a thousand
382
+ // people it occurs in conversation constantly and is aimed at her
383
+ // almost never. Only the `@` counts, and it is the same fact the
384
+ // stricter rung of the ladder is named after.
385
+ const wasMentioned = groupConfig.groupPolicy === "tag"
386
+ ? (0, helpers_1.hasExplicitTelegramMention)({ selfUsername, text: addressableText, message: rawMessage })
387
+ : (0, helpers_1.hasTelegramMention)({
388
+ cfg,
389
+ agentId: route.agentId,
390
+ selfUsername,
391
+ text: addressableText,
392
+ message: rawMessage,
393
+ });
394
+ // One fetch serves two needs: the reply-to-self gate below and
395
+ // the parent's text for the agent (ReplyToBody), which a plain
396
+ // reply does not carry on its own.
397
+ const replyParent = await (0, helpers_1.resolveReplyParent)(rawMessage, { selfId, selfLabel });
398
+ const wasReplyToSelf = replyParent.isSelf;
399
+ const mentionDecision = (0, channel_inbound_1.resolveInboundMentionDecision)({
400
+ facts: {
401
+ canDetectMention: true,
402
+ wasMentioned,
403
+ hasAnyMention: /(^|\s)@[a-zA-Z0-9_]{5,}\b/.test(addressableText),
404
+ },
405
+ policy: {
406
+ isGroup: true,
407
+ requireMention: groupConfig.groupPolicy !== "open",
408
+ allowTextCommands: false,
409
+ hasControlCommand: false,
410
+ commandAuthorized: true,
411
+ },
412
+ });
413
+ log?.info?.("clawgram group mention gate", {
414
+ accountId,
415
+ chatId: normalized.chatId,
416
+ messageId: normalized.messageId,
417
+ selfUsername,
418
+ groupPolicy: groupConfig.groupPolicy,
419
+ mentionedFlag: rawMessage?.mentioned === true,
420
+ hasEntities: Array.isArray(rawMessage?.entities) ? rawMessage.entities.length : 0,
421
+ wasMentioned,
422
+ wasReplyToSelf,
423
+ shouldSkip: mentionDecision.shouldSkip,
424
+ textLength: text.length,
425
+ });
426
+ if (groupConfig.groupPolicy !== "open" && mentionDecision.shouldSkip && !wasReplyToSelf) {
427
+ log?.info?.("clawgram skipping group message without mention", {
428
+ accountId,
429
+ chatId: normalized.chatId,
430
+ messageId: normalized.messageId,
431
+ senderId,
432
+ });
433
+ return;
434
+ }
435
+ const { storePath, body } = buildEnvelope({
436
+ channel: "Telegram",
437
+ from: senderLabel,
438
+ body: text,
439
+ timestamp: normalized.timestamp,
440
+ });
441
+ const conversationRouteTarget = (0, helpers_1.buildConversationTarget)(normalized.chatId);
442
+ const ctxPayload = channelRuntime.reply.finalizeInboundContext({
443
+ Body: body,
444
+ BodyForAgent: text,
445
+ RawBody: text,
446
+ CommandBody: text,
447
+ From: conversationRouteTarget,
448
+ To: conversationRouteTarget,
449
+ SessionKey: route.sessionKey,
450
+ AccountId: route.accountId ?? accountId,
451
+ ChatType: "group",
452
+ ConversationLabel: senderLabel,
453
+ SenderId: senderId,
454
+ SenderUsername: normalized.senderUsername,
455
+ SenderName: normalized.senderDisplay,
456
+ GroupId: normalized.chatId,
457
+ GroupSubject: normalized.chatId,
458
+ WasMentioned: mentionDecision.effectiveWasMentioned || wasReplyToSelf,
459
+ WasReplyToSelf: wasReplyToSelf,
460
+ Provider: "telegram",
461
+ Surface: "clawgram",
462
+ MessageSid: normalized.messageId,
463
+ MessageSidFull: normalized.messageId,
464
+ Timestamp: normalized.timestamp,
465
+ ReplyToId: normalized.replyToMessageId,
466
+ // Core renders these itself as `[Replying to: "…"]` ahead of the
467
+ // user body — it keys off Provider being "telegram", which is set
468
+ // below. Without them a highlighted reply reaches the agent as
469
+ // bare text, and the fragment the person pointed at is lost.
470
+ ReplyToQuoteText: normalized.replyQuoteText,
471
+ ReplyToIsQuote: normalized.replyIsQuote,
472
+ // A plain reply has no highlight; core then falls back to the
473
+ // parent's body, which only exists if the channel fetched it.
474
+ ReplyToBody: replyParent.body,
475
+ ReplyToSender: replyParent.sender,
476
+ MessageThreadId: normalized.messageThreadId,
477
+ NativeChannelId: normalized.chatId,
478
+ // Trusted per-group prompt block from `groups.<id>.systemPrompt`.
479
+ // Core normalizes it (`normalizeTrustedTextField`) and appends
480
+ // it to the system prompt for this turn. Undefined = no block.
481
+ GroupSystemPrompt: groupConfig.systemPrompt,
482
+ OriginatingChannel: "clawgram",
483
+ OriginatingTo: conversationRouteTarget,
484
+ });
485
+ const groupReplyAddress = (0, group_reply_address_1.buildGroupReplyAddress)({
486
+ senderUsername: normalized.senderUsername,
487
+ senderDisplay: normalized.senderDisplay,
488
+ senderId,
489
+ });
490
+ (0, group_reply_address_1.rememberGroupReplyAddress)({
491
+ accountId: route.accountId ?? accountId,
492
+ chatId: normalized.chatId,
493
+ replyToId: normalized.messageId,
494
+ address: groupReplyAddress,
495
+ });
496
+ const messageThreadId = (0, helpers_1.parseOptionalThreadId)(normalized.messageThreadId);
497
+ const groupTypingTarget = normalized.chatId;
498
+ await gram.withTyping(groupTypingTarget, async () => {
499
+ log?.info?.("clawgram dispatching group reply", {
500
+ accountId,
501
+ chatId: normalized.chatId,
502
+ messageId: normalized.messageId,
503
+ routeSessionKey: route.sessionKey,
504
+ storePath,
505
+ });
506
+ await channelRuntime.session.recordInboundSession({
507
+ storePath,
508
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
509
+ ctx: ctxPayload,
510
+ updateLastRoute: {
511
+ sessionKey: route.sessionKey,
512
+ channel: constants_2.CHANNEL_ID,
513
+ to: conversationRouteTarget,
514
+ accountId: route.accountId ?? accountId,
515
+ },
516
+ onRecordError: (err) => {
517
+ log?.info?.("clawgram failed to update group last route", {
518
+ accountId,
519
+ chatId: normalized.chatId,
520
+ messageId: normalized.messageId,
521
+ error: String(err),
522
+ });
523
+ },
524
+ });
525
+ const dispatchBase = (0, inbound_reply_dispatch_1.buildInboundReplyDispatchBase)({
526
+ cfg,
527
+ channel: "clawgram",
528
+ accountId: route.accountId ?? accountId,
529
+ route,
530
+ storePath,
531
+ ctxPayload,
532
+ core: { channel: channelRuntime },
533
+ });
534
+ const { onModelSelected, ...replyPipeline } = (0, channel_reply_pipeline_1.createChannelReplyPipeline)({
535
+ cfg,
536
+ agentId: route.agentId,
537
+ channel: "clawgram",
538
+ accountId: route.accountId ?? accountId,
539
+ });
540
+ // Boundary for the transcript fallback below: only replies
541
+ // written after this instant may be salvaged. Same clock as
542
+ // the transcript writer — both live in this process.
543
+ const dispatchStartedAt = Date.now();
544
+ const dispatchResult = await dispatchBase.dispatchReplyWithBufferedBlockDispatcher({
545
+ ctx: ctxPayload,
546
+ cfg,
547
+ dispatcherOptions: {
548
+ ...replyPipeline,
549
+ deliver: async (payload) => {
550
+ const outboundText = typeof payload.text === "string" ? payload.text.trim() : "";
551
+ log?.info?.("clawgram deliver group payload", {
552
+ accountId,
553
+ chatId: normalized.chatId,
554
+ messageId: normalized.messageId,
555
+ payloadTextLength: outboundText.length,
556
+ payloadReplyToId: payload.replyToId ?? null,
557
+ });
558
+ if (!outboundText) {
559
+ return;
560
+ }
561
+ // The agent may decline to answer by returning the shared
562
+ // silent token. Drop it before addressing: otherwise the
563
+ // reply-address prefix turns it into a visible message.
564
+ const visibleText = (0, helpers_1.stripSilentReplyToken)(outboundText);
565
+ if (!visibleText) {
566
+ log?.info?.("clawgram suppressing silent group reply", {
567
+ accountId,
568
+ chatId: normalized.chatId,
569
+ messageId: normalized.messageId,
570
+ });
571
+ return;
572
+ }
573
+ // Ядро подклеивает свою телеметрию к полезной нагрузке
574
+ // хода, и сюда она приходит тем же путём, что ответ.
575
+ // Проверка стояла только в `outbound.sendText`, то есть
576
+ // класс инцидента 30.08–01.09 был закрыт для рассылок и
577
+ // открыт для обычного ответа на упоминание (A5-10).
578
+ const groupNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
579
+ targetKind: "group",
580
+ text: visibleText,
581
+ });
582
+ if (groupNotice) {
583
+ log?.warn?.("clawgram suppressing system notice in group reply", {
584
+ accountId,
585
+ chatId: normalized.chatId,
586
+ messageId: normalized.messageId,
587
+ noticeKind: groupNotice,
588
+ textLength: visibleText.length,
589
+ });
590
+ return;
591
+ }
592
+ const replyToMessageId = payload.replyToId ? Number(payload.replyToId) : Number(normalized.messageId);
593
+ const rememberedAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
594
+ accountId: route.accountId ?? accountId,
595
+ chatId: normalized.chatId,
596
+ replyToId: payload.replyToId ?? normalized.messageId,
597
+ });
598
+ await sendTextToConversation({
599
+ text: (0, helpers_1.prefixReplyTextToAddress)(visibleText, rememberedAddress ?? groupReplyAddress),
600
+ replyToMessageId,
601
+ messageThreadId,
602
+ });
603
+ },
604
+ onError: (err, info) => {
605
+ log?.error?.("clawgram failed to dispatch group reply", {
606
+ accountId,
607
+ chatId: normalized.chatId,
608
+ messageId: normalized.messageId,
609
+ kind: info.kind,
610
+ error: String(err),
611
+ });
612
+ },
613
+ },
614
+ replyOptions: {
615
+ onModelSelected,
616
+ // `groups.<id>.skills` → core's per-turn skill allowlist.
617
+ // Undefined = inherit the agent's skills; [] = none here.
618
+ skillFilter: groupConfig.skillFilter,
619
+ },
620
+ });
621
+ log?.info?.("clawgram group dispatch completed", {
622
+ accountId,
623
+ chatId: normalized.chatId,
624
+ messageId: normalized.messageId,
625
+ queuedFinal: dispatchResult?.queuedFinal ?? null,
626
+ counts: dispatchResult?.counts ?? null,
627
+ });
628
+ const dispatchCounts = dispatchResult?.counts ?? { tool: 0, block: 0, final: 0 };
629
+ const nothingDelivered = dispatchResult?.queuedFinal !== true &&
630
+ (dispatchCounts.tool ?? 0) === 0 &&
631
+ (dispatchCounts.block ?? 0) === 0 &&
632
+ (dispatchCounts.final ?? 0) === 0;
633
+ if (nothingDelivered) {
634
+ const fallbackText = (0, helpers_1.readLatestAssistantFallbackFromTranscript)(route.sessionKey, storePath, dispatchStartedAt);
635
+ // A suppressed silent reply legitimately delivers nothing, so
636
+ // this fallback fires right after it. Without the same check
637
+ // the token would be read back from the transcript and sent.
638
+ //
639
+ // TTS markup needs the same treatment for the same reason:
640
+ // core strips it on the normal reply path, but this text comes
641
+ // straight out of the transcript. On 2026-08-08 a group got
642
+ // `[[tts:text]]Привет, Вася!…[[/tts:text]]` verbatim. The
643
+ // spoken words are kept — a synthesis that did not happen
644
+ // should degrade to readable text, not to markup.
645
+ const rawFallback = fallbackText
646
+ ? (0, helpers_1.stripTtsDirectives)((0, helpers_1.stripSilentReplyToken)(fallbackText))
647
+ : "";
648
+ // Тот же фильтр и здесь: последняя реплика в стенограмме
649
+ // вполне может оказаться именно уведомлением об ошибке.
650
+ const fallbackNotice = rawFallback
651
+ ? (0, system_notice_1.shouldSuppressGroupSystemNotice)({ targetKind: "group", text: rawFallback })
652
+ : undefined;
653
+ if (fallbackNotice) {
654
+ log?.warn?.("clawgram suppressing system notice in transcript fallback", {
655
+ accountId,
656
+ chatId: normalized.chatId,
657
+ messageId: normalized.messageId,
658
+ noticeKind: fallbackNotice,
659
+ textLength: rawFallback.length,
660
+ });
661
+ }
662
+ const visibleFallbackText = fallbackNotice ? "" : rawFallback;
663
+ if (!visibleFallbackText) {
664
+ if (fallbackText) {
665
+ log?.info?.("clawgram skipping silent transcript fallback", {
666
+ accountId,
667
+ chatId: normalized.chatId,
668
+ messageId: normalized.messageId,
669
+ routeSessionKey: route.sessionKey,
670
+ });
671
+ }
672
+ else {
673
+ log?.warn?.("clawgram transcript fallback unavailable", {
674
+ accountId,
675
+ chatId: normalized.chatId,
676
+ messageId: normalized.messageId,
677
+ routeSessionKey: route.sessionKey,
678
+ });
679
+ }
680
+ // Named, and nothing came back: leave a reaction so the
681
+ // decision is visible instead of reading as her ignoring
682
+ // people. The condition is her silence, not the shape of
683
+ // the transcript — a turn that wrote no entry at all is
684
+ // just as silent as one that wrote the NO_REPLY token.
685
+ //
686
+ // Never allowed to disturb the turn: the reply is already
687
+ // settled by this point, so a failure here stays silent.
688
+ await reactToSilentMentionForAccount({
689
+ cfg,
690
+ accountId,
691
+ gram: runtimes.get(accountId),
692
+ pluginRuntime,
693
+ chatId: normalized.chatId,
694
+ messageId: normalized.messageId,
695
+ messageText: normalized.text,
696
+ // Same sense of "addressed" the agent was given for this
697
+ // turn on line 817: a reply to her own message counts as
698
+ // being spoken to, mention or not.
699
+ wasMentioned: mentionDecision.effectiveWasMentioned || wasReplyToSelf,
700
+ }).catch((err) => {
701
+ log?.info?.("clawgram silent-mention reaction failed", {
702
+ accountId,
703
+ chatId: normalized.chatId,
704
+ messageId: normalized.messageId,
705
+ error: String(err),
706
+ });
707
+ });
708
+ }
709
+ else {
710
+ log?.warn?.("clawgram using transcript fallback reply", {
711
+ accountId,
712
+ chatId: normalized.chatId,
713
+ messageId: normalized.messageId,
714
+ routeSessionKey: route.sessionKey,
715
+ fallbackTextLength: visibleFallbackText.length,
716
+ });
717
+ await sendTextToConversation({
718
+ text: (0, helpers_1.prefixReplyTextToAddress)(visibleFallbackText, groupReplyAddress),
719
+ replyToMessageId: Number(normalized.messageId),
720
+ messageThreadId,
721
+ });
722
+ }
723
+ }
724
+ }, {
725
+ readMessageId: Number(normalized.messageId),
726
+ messageThreadId,
727
+ // The indicator is a promise of an answer, and it is owed only
728
+ // to someone who addressed her. Under `open` the turn runs on
729
+ // every message in the chat, so without this the whole room
730
+ // watches her "type" through conversations she is only reading.
731
+ typing: mentionDecision.effectiveWasMentioned || wasReplyToSelf,
732
+ });
733
+ log?.info?.("clawgram group inbound handled", {
734
+ accountId,
735
+ chatId: normalized.chatId,
736
+ messageId: normalized.messageId,
737
+ senderId,
738
+ senderLabel,
739
+ wasMentioned: mentionDecision.effectiveWasMentioned,
740
+ wasReplyToSelf,
741
+ });
742
+ return;
743
+ }
744
+ if (!(0, helpers_1.isSenderAllowed)({
745
+ allowFrom: directAllowFrom,
746
+ senderId,
747
+ senderUsername: normalized.senderUsername,
748
+ })) {
749
+ log?.info?.("clawgram direct allowFrom mismatch", {
750
+ accountId,
751
+ senderId,
752
+ senderUsername: normalized.senderUsername,
753
+ allowFromCount: Array.isArray(directAllowFrom) ? directAllowFrom.length : 0,
754
+ allowFromHasWildcard: Array.isArray(directAllowFrom) && directAllowFrom.some((e) => String(e).trim() === "*"),
755
+ });
756
+ return;
757
+ }
758
+ const access = await (0, direct_dm_1.resolveInboundDirectDmAccessWithRuntime)({
759
+ cfg,
760
+ channel: "clawgram",
761
+ accountId,
762
+ dmPolicy,
763
+ allowFrom: directAllowFrom,
764
+ senderId,
765
+ rawBody: text,
766
+ runtime: channelRuntime.commands,
767
+ isSenderAllowed: (_candidateSenderId, allowEntries) => (0, helpers_1.isSenderAllowed)({
768
+ allowFrom: allowEntries,
769
+ senderId,
770
+ senderUsername,
771
+ }),
772
+ readStoreAllowFrom: pairing.readStoreForDmPolicy,
773
+ });
774
+ if (access.access.decision === "block") {
775
+ log?.info?.("clawgram blocking inbound direct message", {
776
+ accountId,
777
+ chatId: normalized.chatId,
778
+ messageId: normalized.messageId,
779
+ senderId,
780
+ reason: access.access.reason,
781
+ reasonCode: access.access.reasonCode,
782
+ });
783
+ return;
784
+ }
785
+ if (access.access.decision === "pairing") {
786
+ await pairing.issueChallenge({
787
+ senderId,
788
+ senderIdLine: `Your Telegram user id: ${senderId}`,
789
+ meta: {
790
+ username: normalized.senderUsername,
791
+ name: normalized.senderDisplay,
792
+ },
793
+ sendPairingReply: async (pairingText) => {
794
+ await sendTextToConversation({
795
+ text: pairingText,
796
+ });
797
+ },
798
+ onReplyError: (err) => {
799
+ log?.info?.("clawgram pairing reply failed", {
800
+ accountId,
801
+ chatId: normalized.chatId,
802
+ senderId,
803
+ error: String(err),
804
+ });
805
+ },
806
+ });
807
+ log?.info?.("clawgram pairing required for inbound direct message", {
808
+ accountId,
809
+ chatId: normalized.chatId,
810
+ messageId: normalized.messageId,
811
+ senderId,
812
+ });
813
+ return;
814
+ }
815
+ // Same fetch as the group path. In a DM the parent is as often
816
+ // the agent's own message as the person's — the owner answers a
817
+ // notice she sent — and neither text is available any other way.
818
+ const replyParent = await (0, helpers_1.resolveReplyParent)(rawMessage, { selfId, selfLabel });
819
+ await gram.withTyping(conversationTarget, async () => {
820
+ await (0, direct_dm_1.dispatchInboundDirectDmWithRuntime)({
821
+ cfg,
822
+ runtime: { channel: channelRuntime },
823
+ channel: "clawgram",
824
+ channelLabel: "Telegram",
825
+ accountId,
826
+ peer: {
827
+ kind: "direct",
828
+ id: senderId,
829
+ },
830
+ senderId,
831
+ senderAddress: `telegram:${senderId}`,
832
+ recipientAddress: selfId ? `telegram:${selfId}` : `telegram:${accountId}`,
833
+ conversationLabel: senderLabel,
834
+ rawBody: text,
835
+ messageId: normalized.messageId,
836
+ timestamp: normalized.timestamp,
837
+ commandAuthorized: access.commandAuthorized,
838
+ provider: "telegram",
839
+ surface: "clawgram",
840
+ originatingChannel: "clawgram",
841
+ originatingTo: senderId,
842
+ extraContext: {
843
+ SenderUsername: normalized.senderUsername,
844
+ SenderName: normalized.senderDisplay,
845
+ ReplyToId: normalized.replyToMessageId,
846
+ // Same reason as the group path: highlighted replies happen in
847
+ // direct messages too, and the fragment is not part of the text.
848
+ ReplyToQuoteText: normalized.replyQuoteText,
849
+ ReplyToIsQuote: normalized.replyIsQuote,
850
+ ReplyToBody: replyParent.body,
851
+ ReplyToSender: replyParent.sender,
852
+ NativeChannelId: normalized.chatId,
853
+ },
854
+ deliver: async (payload) => {
855
+ const outboundText = typeof payload.text === "string" ? payload.text.trim() : "";
856
+ if (!outboundText) {
857
+ return;
858
+ }
859
+ const visibleText = (0, helpers_1.stripSilentReplyToken)(outboundText);
860
+ if (!visibleText) {
861
+ log?.info?.("clawgram suppressing silent direct reply", {
862
+ accountId,
863
+ chatId: normalized.chatId,
864
+ messageId: normalized.messageId,
865
+ });
866
+ return;
867
+ }
868
+ // Тот же фильтр, что у группового ответа и у outbound.sendText:
869
+ // личный ответ идёт третьим путём, и закрытие A5-11 его не
870
+ // покрывало — «⚠️ 🛠️ Bash failed: cat /opt/openclaw-secrets/…»
871
+ // уходил любому из allowFrom, чей ход уронил инструмент (B5-01).
872
+ const directNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
873
+ targetKind: "user",
874
+ text: visibleText,
875
+ to: normalized.chatId,
876
+ operatorIds: (0, system_notice_1.operatorIdsFor)(accountId),
877
+ });
878
+ if (directNotice) {
879
+ log?.warn?.("clawgram suppressing system notice in direct reply", {
880
+ accountId,
881
+ chatId: normalized.chatId,
882
+ messageId: normalized.messageId,
883
+ noticeKind: directNotice,
884
+ textLength: visibleText.length,
885
+ });
886
+ return;
887
+ }
888
+ await sendTextToConversation({
889
+ text: visibleText,
890
+ replyToMessageId: payload.replyToId ? Number(payload.replyToId) : undefined,
891
+ });
892
+ },
893
+ onRecordError: (err) => {
894
+ log?.info?.("clawgram failed to record inbound session", {
895
+ accountId,
896
+ chatId: normalized.chatId,
897
+ messageId: normalized.messageId,
898
+ error: String(err),
899
+ });
900
+ },
901
+ onDispatchError: (err, info) => {
902
+ log?.info?.("clawgram failed to dispatch reply", {
903
+ accountId,
904
+ chatId: normalized.chatId,
905
+ messageId: normalized.messageId,
906
+ kind: info.kind,
907
+ error: String(err),
908
+ });
909
+ },
910
+ });
911
+ }, {
912
+ readMessageId: Number(normalized.messageId),
913
+ });
914
+ log?.info?.("clawgram inbound handled", {
915
+ accountId,
916
+ chatId: normalized.chatId,
917
+ messageId: normalized.messageId,
918
+ senderId,
919
+ senderLabel,
920
+ });
921
+ }
922
+ catch (error) {
923
+ const rawMessage = event?.message;
924
+ log?.error?.("clawgram inbound handling failed", {
925
+ accountId,
926
+ chatId: String(rawMessage?.chatId ?? rawMessage?.peerId?.userId ?? rawMessage?.peerId?.chatId ?? rawMessage?.peerId?.channelId ?? ""),
927
+ messageId: String(rawMessage?.id ?? ""),
928
+ error: String(error),
929
+ });
930
+ log?.info?.("clawgram inbound preflight failed", {
931
+ accountId,
932
+ chatId: String(rawMessage?.chatId ?? rawMessage?.peerId?.userId ?? rawMessage?.peerId?.chatId ?? rawMessage?.peerId?.channelId ?? ""),
933
+ messageId: String(rawMessage?.id ?? ""),
934
+ error: String(error),
935
+ });
936
+ }
937
+ }