clawgram 2.19.4 → 2.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/channel.js CHANGED
@@ -70,6 +70,7 @@ const joins_1 = require("./joins");
70
70
  const reactions_1 = require("./reactions");
71
71
  const manage_1 = require("./manage");
72
72
  const silent_reaction_1 = require("./silent-reaction");
73
+ const system_notice_1 = require("./system-notice");
73
74
  const chat_info_1 = require("./chat-info");
74
75
  const topics_1 = require("./topics");
75
76
  const dialogs_1 = require("./dialogs");
@@ -800,7 +801,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
800
801
  text,
801
802
  message: rawMessage,
802
803
  });
803
- const wasReplyToSelf = await (0, helpers_1.isReplyToSelfMessage)(rawMessage, selfId);
804
+ // One fetch serves two needs: the reply-to-self gate below and
805
+ // the parent's text for the agent (ReplyToBody), which a plain
806
+ // reply does not carry on its own.
807
+ const replyParent = await (0, helpers_1.resolveReplyParent)(rawMessage, { selfId, selfLabel });
808
+ const wasReplyToSelf = replyParent.isSelf;
804
809
  const mentionDecision = (0, channel_inbound_1.resolveInboundMentionDecision)({
805
810
  facts: {
806
811
  canDetectMention: true,
@@ -874,6 +879,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
874
879
  // bare text, and the fragment the person pointed at is lost.
875
880
  ReplyToQuoteText: normalized.replyQuoteText,
876
881
  ReplyToIsQuote: normalized.replyIsQuote,
882
+ // A plain reply has no highlight; core then falls back to the
883
+ // parent's body, which only exists if the channel fetched it.
884
+ ReplyToBody: replyParent.body,
885
+ ReplyToSender: replyParent.sender,
877
886
  MessageThreadId: normalized.messageThreadId,
878
887
  NativeChannelId: normalized.chatId,
879
888
  // Trusted per-group prompt block from `groups.<id>.systemPrompt`.
@@ -1178,6 +1187,10 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1178
1187
  });
1179
1188
  return;
1180
1189
  }
1190
+ // Same fetch as the group path. In a DM the parent is as often
1191
+ // the agent's own message as the person's — the owner answers a
1192
+ // notice she sent — and neither text is available any other way.
1193
+ const replyParent = await (0, helpers_1.resolveReplyParent)(rawMessage, { selfId, selfLabel });
1181
1194
  await gram.withTyping(conversationTarget, async () => {
1182
1195
  await (0, direct_dm_1.dispatchInboundDirectDmWithRuntime)({
1183
1196
  cfg,
@@ -1209,6 +1222,8 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
1209
1222
  // direct messages too, and the fragment is not part of the text.
1210
1223
  ReplyToQuoteText: normalized.replyQuoteText,
1211
1224
  ReplyToIsQuote: normalized.replyIsQuote,
1225
+ ReplyToBody: replyParent.body,
1226
+ ReplyToSender: replyParent.sender,
1212
1227
  NativeChannelId: normalized.chatId,
1213
1228
  },
1214
1229
  deliver: async (payload) => {
@@ -2405,6 +2420,24 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
2405
2420
  });
2406
2421
  return { skipped: "silent" };
2407
2422
  }
2423
+ // Core's operational chatter (tool-error warnings, fallback notices)
2424
+ // stays out of group chats: it is telemetry for the operator, not a
2425
+ // reply to the room, and it has already been seen carrying shell
2426
+ // commands with secret-store paths. DMs keep it. The text itself is
2427
+ // never logged — see system-notice.ts for why.
2428
+ const suppressedNotice = (0, system_notice_1.shouldSuppressGroupSystemNotice)({
2429
+ targetKind: (0, helpers_1.inferOutboundTargetKind)(ctx.to),
2430
+ text: ctx.text,
2431
+ });
2432
+ if (suppressedNotice) {
2433
+ actionLog.warn("clawgram suppressing system notice in group", {
2434
+ accountId: ctx.accountId,
2435
+ rawTo: ctx.to,
2436
+ noticeKind: suppressedNotice,
2437
+ textLength: ctx.text.length,
2438
+ });
2439
+ return { skipped: "system-notice" };
2440
+ }
2408
2441
  const gram = runtimes.get(ctx.accountId);
2409
2442
  if (!gram) {
2410
2443
  throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
package/dist/helpers.js CHANGED
@@ -36,6 +36,7 @@ exports.prefixReplyTextToAddress = prefixReplyTextToAddress;
36
36
  exports.resolveReplyTarget = resolveReplyTarget;
37
37
  exports.resolveChatTarget = resolveChatTarget;
38
38
  exports.isReplyToSelfMessage = isReplyToSelfMessage;
39
+ exports.resolveReplyParent = resolveReplyParent;
39
40
  exports.resolveSenderProfile = resolveSenderProfile;
40
41
  exports.resolveSenderProfileWithTimeout = resolveSenderProfileWithTimeout;
41
42
  exports.normalizeParseMode = normalizeParseMode;
@@ -549,27 +550,58 @@ async function resolveChatTarget(message) {
549
550
  }
550
551
  return message?.inputChat ?? message?._inputChat ?? message?.chat ?? message?._chat ?? message?.peerId;
551
552
  }
552
- async function isReplyToSelfMessage(message, selfId) {
553
- if (!selfId) {
554
- return false;
555
- }
553
+ /**
554
+ * The message a reply points at, fetched once.
555
+ *
556
+ * Telegram does not put the parent's text into the reply; a highlight
557
+ * (`quoteText`) is the only fragment that travels with it, and most replies
558
+ * have none. Core renders `[Replying to: …]` from the highlight or, failing
559
+ * that, from `ReplyToBody` — so without this fetch a plain reply reaches the
560
+ * agent as a bare parent id. The case that exposed it: the owner answered, in
561
+ * a DM, the agent's own notice about an unknown sender; the notice had been
562
+ * sent from another session, DMs are outside `readChats`, and the agent had
563
+ * no way to learn what "reply to #1011" referred to.
564
+ *
565
+ * Failure degrades to "no context" on purpose: a parent that cannot be
566
+ * fetched (deleted, flood-waited, transport hiccup) must not cost the
567
+ * message itself.
568
+ */
569
+ async function resolveReplyParent(message, input) {
556
570
  const replyToMessageId = message?.replyTo?.replyToMsgId ?? message?.replyToMsgId;
557
571
  if (!replyToMessageId) {
558
- return false;
572
+ return { isSelf: false };
559
573
  }
560
574
  const replied = typeof message?.getReplyMessage === "function"
561
575
  ? await message.getReplyMessage().catch(() => undefined)
562
576
  : undefined;
563
577
  if (!replied) {
564
- return false;
565
- }
566
- if (replied.out === true) {
567
- return true;
578
+ return { isSelf: false };
568
579
  }
569
580
  const replySenderId = replied.senderId ??
570
581
  replied.fromId?.userId ??
571
582
  replied.fromId?.channelId;
572
- return replySenderId !== undefined && String(replySenderId) === selfId;
583
+ const isSelf = replied.out === true ||
584
+ (input.selfId !== undefined && replySenderId !== undefined && String(replySenderId) === input.selfId);
585
+ const rawText = typeof replied.message === "string" ? replied.message :
586
+ typeof replied.text === "string" ? replied.text :
587
+ "";
588
+ const body = rawText.trim() ? rawText : undefined;
589
+ const source = replied.sender ?? replied._sender;
590
+ const sender = isSelf
591
+ ? (input.selfLabel ?? (input.selfId !== undefined ? input.selfId : undefined))
592
+ : toDisplayName({
593
+ firstName: typeof source?.firstName === "string" ? source.firstName : undefined,
594
+ lastName: typeof source?.lastName === "string" ? source.lastName : undefined,
595
+ username: undefined,
596
+ fallback: resolveActiveUsername(source) ? `@${resolveActiveUsername(source)}` : (replySenderId !== undefined ? String(replySenderId) : undefined),
597
+ });
598
+ return { isSelf, body, sender: sender || undefined };
599
+ }
600
+ async function isReplyToSelfMessage(message, selfId) {
601
+ if (!selfId) {
602
+ return false;
603
+ }
604
+ return (await resolveReplyParent(message, { selfId })).isSelf;
573
605
  }
574
606
  async function resolveSenderProfile(message, input) {
575
607
  const pickProfile = (source) => {
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ /**
3
+ * Core's operational chatter — and why a group chat never sees it.
4
+ *
5
+ * When a tool call fails or the model silently degrades, core appends a
6
+ * status line to the turn's outbound payloads: `⚠️ 🛠️ Exec failed: …`,
7
+ * `⚠️ ✉️ Message failed`, `↪️ Model Fallback: …`. In a DM with the owner that
8
+ * is legitimate telemetry. In a group it is the assistant narrating its own
9
+ * kitchen to an audience the message was never for — measured three days in a
10
+ * row in the owner's work chat (2026-08-30 … 09-01): a jq stack trace with
11
+ * server paths, a bare "Message failed", an exec step list. The people in the
12
+ * chat cannot act on any of it, and the owner reads it as the assistant
13
+ * being broken.
14
+ *
15
+ * Detection is by core's own exact prefixes, not by keyword: the assistant is
16
+ * allowed to *say* "⚠️" or discuss a failure in its own words — only core's
17
+ * machine-built notices match. The list mirrors what core actually emits
18
+ * (`isCronToolWarning` matches `⚠️ 🛠️ ` verbatim; the message-failed check is
19
+ * the same normalized comparison core uses; fallback notices are built by
20
+ * `buildFallbackNotice`/`buildFallbackClearedNotice`).
21
+ *
22
+ * Nothing is lost by dropping them here: the same failures live in the run's
23
+ * diagnostics, the cron job's `lastError` and the gateway log. The drop is
24
+ * logged with the notice's class and length — never its text, which has
25
+ * already been seen carrying secret-store paths and full shell commands.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.classifySystemNotice = classifySystemNotice;
29
+ exports.shouldSuppressGroupSystemNotice = shouldSuppressGroupSystemNotice;
30
+ const TOOL_WARNING_PREFIX = "⚠️ 🛠️ ";
31
+ const MESSAGE_FAILED_PREFIX = "⚠️ ✉️ message failed";
32
+ const FALLBACK_NOTICE_PREFIX = "↪️ model fallback";
33
+ /**
34
+ * Classifies core's operational status lines. `undefined` means the text is a
35
+ * real reply and must be delivered untouched.
36
+ */
37
+ function classifySystemNotice(text) {
38
+ const trimmed = text.trim();
39
+ if (!trimmed) {
40
+ return undefined;
41
+ }
42
+ if (trimmed.startsWith(TOOL_WARNING_PREFIX)) {
43
+ return "tool-warning";
44
+ }
45
+ const lower = trimmed.toLowerCase();
46
+ if (lower === MESSAGE_FAILED_PREFIX || lower.startsWith(`${MESSAGE_FAILED_PREFIX}:`)) {
47
+ return "message-failed";
48
+ }
49
+ // Covers both "↪️ Model Fallback: …" and "↪️ Model Fallback cleared: …".
50
+ if (lower.startsWith(FALLBACK_NOTICE_PREFIX)) {
51
+ return "model-fallback";
52
+ }
53
+ return undefined;
54
+ }
55
+ /**
56
+ * A notice glued to a real reply is not a notice: `classifySystemNotice` looks
57
+ * at the start of the text on purpose, so an answer that quotes or discusses a
58
+ * warning still goes out. Only a payload that IS the notice — the whole text,
59
+ * possibly with the group-address prefix core adds — is suppressible.
60
+ *
61
+ * The address prefix ("@name, ") is applied by the channel after this check,
62
+ * so the text seen here is core's payload verbatim.
63
+ */
64
+ function shouldSuppressGroupSystemNotice(params) {
65
+ // DMs keep the telemetry: there the reader is the person running the agent.
66
+ if (params.targetKind !== "group" && params.targetKind !== "channel") {
67
+ return undefined;
68
+ }
69
+ return classifySystemNotice(params.text);
70
+ }
@@ -2,7 +2,7 @@
2
2
  "id": "clawgram",
3
3
  "name": "Clawgram",
4
4
  "description": "Clawgram — personal Telegram (MTProto userbot) channel for OpenClaw. Your AI assistant reads and responds as you.",
5
- "version": "2.19.4",
5
+ "version": "2.20.2",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.19.4",
3
+ "version": "2.20.2",
4
4
  "description": "Clawgram — personal Telegram (MTProto userbot) channel for OpenClaw. Your AI assistant reads and responds as you.",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {