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