clawgram 2.28.1 → 2.29.1

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/README.md CHANGED
@@ -31,6 +31,7 @@ Clawgram is a personal-Telegram channel plugin for [OpenClaw](https://github.com
31
31
  - **@Mention detection** — respond only when mentioned in groups (text, caption, and ID-based mentions)
32
32
  - **Read receipts** — mark messages as read
33
33
  - **Emoji reactions** — acknowledge a message with a reaction instead of a reply (`react` action)
34
+ - **Editing what was sent** — rewrite the account's own message in place instead of posting a correction beneath it (`edit` action)
34
35
  - **Chat metadata** — title, type, member count, description, forum flag and pinned message (`chatInfo` action)
35
36
  - **Attachments on demand** — fetch the photo or voice note on any message in read scope, as a reading, a file, or both (`fetch-media` action)
36
37
  - **Chat management** — create supergroups, add/remove members, promote/demote admins, transfer ownership, export invite links (`createGroup`, `addMembers`, `removeMember`, `promoteAdmin`, `demoteAdmin`, `transferOwnership`, `inviteLink`) — off until `manageChats` allows it
@@ -260,7 +261,7 @@ loud where it does occur.
260
261
  | `groups` | object | `{}` | Allowed groups map keyed by explicit group id or `*` |
261
262
  | `proxy` | object | unset | Optional SOCKS4/SOCKS5 proxy for this account — see [Proxy (SOCKS4/SOCKS5)](#proxy-socks4socks5) |
262
263
  | `manageChats` | string[] | unset | Chats the assistant may **manage** — see [Chat management](#chat-management). Absent or empty = management off; `["*"]` = every chat |
263
- | `sendChats` | string[] | unset | Chats the assistant may **send to** — `send`, `upload-file`, `react` and core's own delivery path (`--deliver`, sub-agent announcements). Absent = every chat; `[]` = none. Phone-number targets are refused regardless (2.18.0; core delivery covered since 2.25.0) |
264
+ | `sendChats` | string[] | unset | Chats the assistant may **send to** — `send`, `upload-file`, `react`, `edit` and core's own delivery path (`--deliver`, sub-agent announcements). Absent = every chat; `[]` = none. Phone-number targets are refused regardless (2.22.0; core delivery covered since 2.25.0) |
264
265
  | `replyParseMode` | `"html"` \| `"markdown"` \| `"none"` | unset | Outbound format for replies, core-delivered text, captions and `send` calls that omit `parseMode` — see [Message formatting](#message-formatting) |
265
266
  | `twoFaPassword` | string \| SecretRef | unset | The account's Telegram 2FA password; read only by `transferOwnership` |
266
267
  | `reactionModel` | string | unset | Model ref or alias for the emoji pick on a silent mention. Unset = the agent's own model. Needs `plugins.entries.clawgram.llm.allowModelOverride: true` in the gateway config; without it the override is refused and the pick quietly falls back to the default model |
@@ -133,116 +133,133 @@ async function handleReadAction(ctx) {
133
133
  : node_path_1.default.join(node_os_1.default.tmpdir(), `clawgram-${typeof process.getuid === "function" ? process.getuid() : "user"}`);
134
134
  await (0, media_1.ensurePrivateDir)(mediaRoot);
135
135
  const sharedFetchDir = node_path_1.default.join(mediaRoot, "clawgram-fetched");
136
+ const described = (0, media_1.describeMedia)(found.message?.media);
137
+ // A PDF is not read here: the agent is handed the path for its PDF tool,
138
+ // so even in `read` mode the file has to outlive the answer. It goes to
139
+ // the shared directory, which is pruned by age — a private temp directory
140
+ // is removed only by the `read` branch below, and a PDF skips that branch,
141
+ // so every PDF read used to stay on disk for good (audit r3 C2-02).
142
+ const keepsFile = fetchParams.mode !== "read" || (0, media_1.fetchMediaUnderstanding)(described) === "pdf";
136
143
  let fetchDir = sharedFetchDir;
137
- if (fetchParams.mode === "read") {
138
- const { mkdtemp } = await import("node:fs/promises");
139
- fetchDir = await mkdtemp(node_path_1.default.join(mediaRoot, "clawgram-media-"));
144
+ if (keepsFile) {
145
+ await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
140
146
  }
141
147
  else {
142
- await (0, media_1.pruneFetchedMedia)(sharedFetchDir, FETCHED_MEDIA_TTL_MS, Date.now());
148
+ const { mkdtemp } = await import("node:fs/promises");
149
+ fetchDir = await mkdtemp(node_path_1.default.join(mediaRoot, "clawgram-media-"));
143
150
  }
144
- const described = (0, media_1.describeMedia)(found.message?.media);
145
- const downloaded = await (0, media_1.downloadMessageMediaToFile)({
146
- client: fetchGram.getClient(),
147
- message: found.message,
148
- maxBytes: attachments_1.INBOUND_MEDIA_MAX_BYTES,
149
- dir: fetchDir,
150
- understanding: (0, media_1.fetchMediaUnderstanding)(described),
151
- fileNameFor: ({ media, extension }) => (0, fetch_media_1.fetchedMediaFileName)({
152
- chatId: fetchChatId,
153
- messageId: fetchParams.messageId,
154
- extension,
155
- fileName: media.fileName,
156
- }),
157
- });
158
- if (!downloaded) {
159
- // Three different nothings, and the agent has to be able to tell
160
- // them apart: a message with no attachment, an attachment this
161
- // channel does not read (a video, a spreadsheet), and one too
162
- // large to be worth the transfer. Saying "could not fetch" to all
163
- // three is how "she ignored the picture" starts.
164
- const tooLarge = typeof described?.size === "number" && described.size > attachments_1.INBOUND_MEDIA_MAX_BYTES;
165
- const error = !described
166
- ? "no-media"
167
- : tooLarge
168
- ? "media-too-large"
169
- : "unsupported-media";
170
- actionLog.info("clawgram fetch-media returned nothing", {
151
+ // Every way out of here removes the private temp directory — the early
152
+ // "nothing to fetch" answer and a download that throws included. Only the
153
+ // success branch used to, so a message without media, a video, or a
154
+ // failed download each left a directory behind for good (audit r3 V1-10).
155
+ try {
156
+ const downloaded = await (0, media_1.downloadMessageMediaToFile)({
157
+ client: fetchGram.getClient(),
158
+ message: found.message,
159
+ maxBytes: attachments_1.INBOUND_MEDIA_MAX_BYTES,
160
+ dir: fetchDir,
161
+ understanding: (0, media_1.fetchMediaUnderstanding)(described),
162
+ fileNameFor: ({ media, extension }) => (0, fetch_media_1.fetchedMediaFileName)({
163
+ chatId: fetchChatId,
164
+ messageId: fetchParams.messageId,
165
+ extension,
166
+ fileName: media.fileName,
167
+ }),
168
+ });
169
+ if (!downloaded) {
170
+ // Three different nothings, and the agent has to be able to tell
171
+ // them apart: a message with no attachment, an attachment this
172
+ // channel does not read (a video, a spreadsheet), and one too
173
+ // large to be worth the transfer. Saying "could not fetch" to all
174
+ // three is how "she ignored the picture" starts.
175
+ const tooLarge = typeof described?.size === "number" && described.size > attachments_1.INBOUND_MEDIA_MAX_BYTES;
176
+ const error = !described
177
+ ? "no-media"
178
+ : tooLarge
179
+ ? "media-too-large"
180
+ : "unsupported-media";
181
+ actionLog.info("clawgram fetch-media returned nothing", {
182
+ accountId: fetchAccountId,
183
+ chatId: fetchChatId,
184
+ messageId: fetchParams.messageId,
185
+ kind: described?.kind ?? null,
186
+ error,
187
+ });
188
+ return (0, core_1.jsonResult)({
189
+ ok: false,
190
+ accountId: fetchAccountId,
191
+ chatId: fetchChatId,
192
+ messageId: String(fetchParams.messageId),
193
+ media: described ?? null,
194
+ error,
195
+ });
196
+ }
197
+ let read;
198
+ let readError;
199
+ if (fetchParams.mode !== "file") {
200
+ try {
201
+ read = await (0, attachments_1.understandAttachmentFile)({
202
+ runtime: pluginRuntime,
203
+ cfg,
204
+ filePath: downloaded.path,
205
+ mimeType: downloaded.mimeType,
206
+ understanding: downloaded.understanding,
207
+ fileName: downloaded.media.fileName,
208
+ });
209
+ if (!read) {
210
+ readError = "read-empty";
211
+ }
212
+ }
213
+ catch (err) {
214
+ // The bytes are already here. A failed reading is worth
215
+ // reporting, but it does not undo a successful fetch: the file
216
+ // still exists and can still be forwarded.
217
+ readError = String(err);
218
+ }
219
+ }
220
+ // `read` mode is the inbound contract — the words, not the file — so
221
+ // the bytes go away with the answer. Any other mode keeps them:
222
+ // that is the whole point of asking for a path.
223
+ const pdfNeedsFile = downloaded.understanding === "pdf";
224
+ const finalReadError = pdfNeedsFile ? "use the PDF tool on filePath to read this PDF" : readError;
225
+ actionLog.info("clawgram fetch-media completed", {
171
226
  accountId: fetchAccountId,
172
227
  chatId: fetchChatId,
173
228
  messageId: fetchParams.messageId,
174
- kind: described?.kind ?? null,
175
- error,
229
+ mode: fetchParams.mode,
230
+ kind: downloaded.media.kind,
231
+ understanding: downloaded.understanding,
232
+ characters: read?.length ?? 0,
233
+ readError: finalReadError ?? null,
176
234
  });
177
235
  return (0, core_1.jsonResult)({
178
- ok: false,
236
+ ok: true,
179
237
  accountId: fetchAccountId,
180
238
  chatId: fetchChatId,
181
239
  messageId: String(fetchParams.messageId),
182
- media: described ?? null,
183
- error,
240
+ mode: fetchParams.mode,
241
+ media: downloaded.media,
242
+ understanding: downloaded.understanding,
243
+ filePath: fetchParams.mode === "read" && !pdfNeedsFile ? undefined : downloaded.path,
244
+ text: read,
245
+ readError: finalReadError,
184
246
  });
185
247
  }
186
- let read;
187
- let readError;
188
- if (fetchParams.mode !== "file") {
189
- try {
190
- read = await (0, attachments_1.understandAttachmentFile)({
191
- runtime: pluginRuntime,
192
- cfg,
193
- filePath: downloaded.path,
194
- mimeType: downloaded.mimeType,
195
- understanding: downloaded.understanding,
196
- fileName: downloaded.media.fileName,
197
- });
198
- if (!read) {
199
- readError = "read-empty";
248
+ finally {
249
+ // Only the private temp directory is ever removed here — never the
250
+ // shared one, which other fetches still point at.
251
+ if (!keepsFile) {
252
+ try {
253
+ const { rm } = await import("node:fs/promises");
254
+ await rm(fetchDir, { recursive: true, force: true });
255
+ }
256
+ catch {
257
+ // A private temp directory left behind is litter, not a leak of
258
+ // anything the agent was not already shown; failing the call over it
259
+ // would throw away a reading that already succeeded.
200
260
  }
201
- }
202
- catch (err) {
203
- // The bytes are already here. A failed reading is worth
204
- // reporting, but it does not undo a successful fetch: the file
205
- // still exists and can still be forwarded.
206
- readError = String(err);
207
- }
208
- }
209
- // `read` mode is the inbound contract — the words, not the file — so
210
- // the bytes go away with the answer. Any other mode keeps them:
211
- // that is the whole point of asking for a path.
212
- const pdfNeedsFile = downloaded.understanding === "pdf";
213
- if (fetchParams.mode === "read" && !pdfNeedsFile) {
214
- try {
215
- const { rm } = await import("node:fs/promises");
216
- await rm(fetchDir, { recursive: true, force: true });
217
- }
218
- catch {
219
- // A file left behind is pruned within a day; failing the call
220
- // over it would throw away a reading that already succeeded.
221
261
  }
222
262
  }
223
- const finalReadError = pdfNeedsFile ? "use the PDF tool on filePath to read this PDF" : readError;
224
- actionLog.info("clawgram fetch-media completed", {
225
- accountId: fetchAccountId,
226
- chatId: fetchChatId,
227
- messageId: fetchParams.messageId,
228
- mode: fetchParams.mode,
229
- kind: downloaded.media.kind,
230
- understanding: downloaded.understanding,
231
- characters: read?.length ?? 0,
232
- readError: finalReadError ?? null,
233
- });
234
- return (0, core_1.jsonResult)({
235
- ok: true,
236
- accountId: fetchAccountId,
237
- chatId: fetchChatId,
238
- messageId: String(fetchParams.messageId),
239
- mode: fetchParams.mode,
240
- media: downloaded.media,
241
- understanding: downloaded.understanding,
242
- filePath: fetchParams.mode === "read" && !pdfNeedsFile ? undefined : downloaded.path,
243
- text: read,
244
- readError: finalReadError,
245
- });
246
263
  }
247
264
  /**
248
265
  * The scaffold every chat-shaped read shares.
@@ -4,6 +4,7 @@ exports.handleSendAction = handleSendAction;
4
4
  const core_1 = require("openclaw/plugin-sdk/core");
5
5
  const param_readers_1 = require("openclaw/plugin-sdk/param-readers");
6
6
  const account_scopes_1 = require("./account-scopes");
7
+ const edits_1 = require("./edits");
7
8
  const group_reply_address_1 = require("./group-reply-address");
8
9
  const group_visible_reply_guard_1 = require("./group-visible-reply-guard");
9
10
  const helpers_1 = require("./helpers");
@@ -30,7 +31,7 @@ async function handleSendAction(ctx) {
30
31
  if (!reactionAccountId) {
31
32
  throw new Error("clawgram: no configured account found");
32
33
  }
33
- // Реакция — видимое действие от имени владельца в чужом чате, и
34
+ // Реакция — видимое действие от имени этой учётки в чужом чате, и
34
35
  // адресуется она так же, как сообщение: та же область (A5-12).
35
36
  if (!(0, send_scope_1.isChatSendable)(reactionParams.target, (0, account_scopes_1.resolveAccountSendChats)(cfg, reactionAccountId))) {
36
37
  (0, account_scopes_1.refuseOutboundOutsideScope)("react", reactionAccountId, String(reactionParams.target));
@@ -62,6 +63,66 @@ async function handleSendAction(ctx) {
62
63
  removed: reactionParams.remove,
63
64
  });
64
65
  }
66
+ // Rewriting a message already sent. Gated exactly like `react`: it is a
67
+ // visible act in someone else's chat under this account's name, so it takes
68
+ // the outbound scope check and honours `dryRun`, which reading does not need.
69
+ if (canonical === "edit") {
70
+ const editParams = (0, edits_1.parseEditParams)(params, toolContext);
71
+ const editAccountId = resolveRuntimeAccountId(cfg, accountId);
72
+ if (!editAccountId) {
73
+ throw new Error("clawgram: no configured account found");
74
+ }
75
+ if (!(0, send_scope_1.isChatSendable)(editParams.target, (0, account_scopes_1.resolveAccountSendChats)(cfg, editAccountId))) {
76
+ (0, account_scopes_1.refuseOutboundOutsideScope)("edit", editAccountId, editParams.target);
77
+ }
78
+ // The same sentinel guard as `send`, for the same reason: `NO_REPLY` is
79
+ // OpenClaw's "say nothing", and an explicit tool call is not a path that
80
+ // strips it. Editing a real answer down to the token would replace a good
81
+ // message with what looks like a malfunction — worse than the send case,
82
+ // because the original text is gone.
83
+ if ((0, helpers_1.isSilentReplyText)(editParams.text)) {
84
+ throw new Error("clawgram: edit refuses a silent-reply sentinel as the new text");
85
+ }
86
+ // No reply-address prefix, no visible-reply memory, no turn-send memory:
87
+ // those three exist to shape and de-duplicate NEW messages. An edit
88
+ // addresses nobody afresh and adds nothing to the chat, so recording it as
89
+ // "this turn has spoken" would suppress a later genuine answer.
90
+ const editParseMode = (0, helpers_1.resolveOutboundParseMode)(params, cfg, editAccountId);
91
+ actionLog.info("clawgram handleAction edit", {
92
+ accountId: editAccountId,
93
+ dryRun: dryRun === true,
94
+ target: editParams.target,
95
+ messageId: editParams.messageId,
96
+ });
97
+ if (dryRun === true) {
98
+ return (0, core_1.jsonResult)({
99
+ ok: true,
100
+ dryRun: true,
101
+ accountId: editAccountId,
102
+ chatId: editParams.target,
103
+ messageId: editParams.messageId,
104
+ });
105
+ }
106
+ const editGram = requireRuntimeFor(editAccountId);
107
+ await editGram.editText({
108
+ target: editParams.target,
109
+ messageId: editParams.messageId,
110
+ text: editParams.text,
111
+ parseMode: editParseMode,
112
+ });
113
+ actionLog.info("clawgram handleAction edit completed", {
114
+ accountId: editAccountId,
115
+ target: editParams.target,
116
+ messageId: editParams.messageId,
117
+ });
118
+ return (0, core_1.jsonResult)({
119
+ ok: true,
120
+ edited: true,
121
+ accountId: editAccountId,
122
+ chatId: editParams.target,
123
+ messageId: editParams.messageId,
124
+ });
125
+ }
65
126
  // Core normalizes whichever of these it filled in to a local path (see
66
127
  // `mediaSourceParams` above); `mediaUrl` stays a URL, which GramJS
67
128
  // accepts as well.
package/dist/actions.js CHANGED
@@ -30,6 +30,14 @@ const ACTION_ALIASES = {
30
30
  list: "read",
31
31
  react: "react",
32
32
  joins: "joins",
33
+ // Rewriting a message already sent (2.29.0). Core has always known the name
34
+ // — it is in `CHANNEL_MESSAGE_ACTION_NAMES` — so the call reached this
35
+ // channel and was refused as unsupported, and a wrong answer could only be
36
+ // followed by a second message correcting it.
37
+ edit: "edit",
38
+ editMessage: "edit",
39
+ "edit-message": "edit",
40
+ update: "edit",
33
41
  "upload-file": "upload-file",
34
42
  sendAttachment: "upload-file",
35
43
  "fetch-media": "fetch-media",
@@ -108,6 +116,11 @@ function canonicalAction(action) {
108
116
  * call fell through to the current chat and answered about the wrong one.
109
117
  * `readChatTargetParam` is the single list of accepted spellings now.
110
118
  *
119
+ * `edit` (2.29.0) is the third shape: core resolves it as a *message* target,
120
+ * so both the chat and `messageId` arrive named. It needs no synonym — the
121
+ * name core uses is the name this channel uses — and it is listed here only so
122
+ * the suite asserts core still knows it.
123
+ *
111
124
  * These spellings are derived from `ACTION_ALIASES` rather than kept beside
112
125
  * it; that core actually knows each of them is asserted against the installed
113
126
  * core in `core-action-synonyms.test.ts`.
@@ -115,6 +128,7 @@ function canonicalAction(action) {
115
128
  const CORE_VOCABULARY_SPELLINGS = [
116
129
  "thread-list", "channel-list", "channel-info", "member-info", "download-file",
117
130
  "channel-create", "addParticipant", "kick", "role-add", "role-remove",
131
+ "edit",
118
132
  ];
119
133
  exports.CORE_ACTION_SYNONYMS = Object.fromEntries(CORE_VOCABULARY_SPELLINGS.map((name) => [name, ACTION_ALIASES[name]]));
120
134
  /** Canonical actions that go through the chat-management gate. */
package/dist/channel.js CHANGED
@@ -454,6 +454,9 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
454
454
  // one. `joins` likewise.
455
455
  actions: [
456
456
  "send", "read", "react", "upload-file",
457
+ // Rewriting a message this account already sent (2.29.0). Core
458
+ // knows the name, so it needs no synonym — unlike the reads below.
459
+ "edit",
457
460
  // Reading an attachment that is already in a chat. `read` reports
458
461
  // that a photo exists; this is what turns it into something the
459
462
  // agent can look at or pass on.
package/dist/edits.js ADDED
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ /**
3
+ * Editing a message already sent — the `edit` action of OpenClaw's message
4
+ * tool.
5
+ *
6
+ * Telegram lets an account rewrite its own message in place, and until 2.29.0
7
+ * this channel had no way to ask for it: a wrong answer could only be followed
8
+ * by a second message correcting the first, which leaves both standing in the
9
+ * chat. `edit` is in core's `CHANNEL_MESSAGE_ACTION_NAMES`, so the name was
10
+ * reachable from the tool the whole time and simply arrived at a channel that
11
+ * refused it.
12
+ *
13
+ * Parsing lives here, apart from the network, so the argument handling can be
14
+ * tested without a Telegram connection — the same split as `reactions.ts`.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.parseEditParams = parseEditParams;
18
+ const helpers_1 = require("./helpers");
19
+ const history_1 = require("./history");
20
+ function parseEditParams(params, toolContext) {
21
+ const rawTarget = (0, helpers_1.readChatTargetParam)(params, toolContext);
22
+ const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
23
+ if (!target) {
24
+ throw new Error("clawgram: edit requires a chatId");
25
+ }
26
+ // No fall-back to `currentMessageId`, and that is the difference from
27
+ // `react`. The current message is the one being answered — someone else's.
28
+ // Reacting to it is the normal case; editing it is never a thing this
29
+ // account may do, so a missing id is a caller mistake and not an invitation
30
+ // to guess. Telegram would refuse it, but the refusal would arrive as
31
+ // MESSAGE_AUTHOR_REQUIRED on a call nobody meant to make.
32
+ const messageId = (0, history_1.parseMessageId)(params.messageId ?? params.msgId ?? params.message_id, "edit messageId");
33
+ if (messageId === undefined) {
34
+ throw new Error("clawgram: edit requires a messageId — the id of the message to rewrite");
35
+ }
36
+ // `readMessageText` reads `message` then `text`, exactly as `send` does, so
37
+ // the same call shape works for both actions.
38
+ const text = (0, helpers_1.readMessageText)(params).replaceAll("\\n", "\n");
39
+ if (!text.trim()) {
40
+ // Telegram has no "edit to nothing": an empty edit is refused server-side,
41
+ // and a caller who meant to remove the message asked for the wrong action.
42
+ // Saying so here beats an MTProto error about message content.
43
+ throw new Error("clawgram: edit requires the new text — an empty edit is not a deletion");
44
+ }
45
+ return { target, messageId, text };
46
+ }
@@ -427,6 +427,36 @@ class GramJsClientManager {
427
427
  ...replyParams,
428
428
  });
429
429
  }
430
+ /**
431
+ * Rewrites a message this account already sent.
432
+ *
433
+ * Rendering mirrors `sendText` deliberately: an edit that parsed its text
434
+ * differently from the send would change the formatting of a message nobody
435
+ * asked to reformat — markdown shipped as literal asterisks is exactly the
436
+ * failure that made `sendText` render HTML itself.
437
+ *
438
+ * No chunking here, unlike `sendText`. A send too long for Telegram becomes
439
+ * several messages; an edit cannot — there is one message to rewrite. Over
440
+ * the limit the call fails, and that is the honest outcome.
441
+ *
442
+ * Telegram's own refusals are left to reach the caller as they are:
443
+ * MESSAGE_AUTHOR_REQUIRED (not this account's message),
444
+ * MESSAGE_EDIT_TIME_EXPIRED (past the window Telegram allows) and
445
+ * MESSAGE_NOT_MODIFIED (the new text equals the old one). None of them is
446
+ * retryable, and dressing them up would hide which one happened.
447
+ */
448
+ async editText(args) {
449
+ const resolved = await this.resolvePeer(args.target);
450
+ return this.client.editMessage(resolved.peer, {
451
+ message: args.messageId,
452
+ text: args.parseMode === "html" ? (0, html_render_1.renderTelegramHtml)(args.text) : args.text,
453
+ ...(args.parseMode === "none"
454
+ ? { parseMode: false }
455
+ : args.parseMode
456
+ ? { parseMode: args.parseMode === "markdown" ? "md" : "html" }
457
+ : {}),
458
+ });
459
+ }
430
460
  /**
431
461
  * Reads what a chat is: title, type, member count, description, pinned
432
462
  * message.
@@ -82,7 +82,7 @@ function buildGroupReplyAddress(input) {
82
82
  // No handle and no name means no greeting, not a numeric one. The id
83
83
  // used to be the fallback, and in a basic group — where GramJS attaches
84
84
  // no sender profile to the message — every reply of a day opened with
85
- // «890975818, …» (07.09.2026). A person does not know their own
85
+ // «<numeric id>, …» (07.09.2026). A person does not know their own
86
86
  // telegram id and reads it as a malfunction.
87
87
  return undefined;
88
88
  }
@@ -499,7 +499,7 @@ async function handleInboundEvent(event, ctx) {
499
499
  // and in a basic group (or after a restart) that cache is empty: the
500
500
  // turn then had nothing but the numeric id, the reply greeting fell
501
501
  // back to it, and a management chat spent 07.09.2026 being addressed
502
- // as «890975818, …». One source of truth for the name: the address
502
+ // as «<numeric id>, …». One source of truth for the name: the address
503
503
  // the channel would prepend is also what the agent reads, so the
504
504
  // model cannot greet «Вася Ш.» while the channel greets «@vasya».
505
505
  await resolveNamelessSender(normalized, rawMessage, client);
package/dist/media.js CHANGED
@@ -28,6 +28,7 @@ exports.assertLocalMediaWithinRoots = assertLocalMediaWithinRoots;
28
28
  exports.loadOutboundMedia = loadOutboundMedia;
29
29
  const node_fs_1 = require("node:fs");
30
30
  const node_path_1 = __importDefault(require("node:path"));
31
+ const docx_text_1 = require("./docx-text");
31
32
  const util_1 = require("./util");
32
33
  /**
33
34
  * GramJS carries numbers as `big-integer` objects as often as native numbers —
@@ -131,19 +132,17 @@ function fetchMediaUnderstanding(media) {
131
132
  const inbound = inboundMediaUnderstanding(media);
132
133
  if (inbound)
133
134
  return inbound;
134
- const fileName = media?.fileName?.toLowerCase() ?? "";
135
- if (media?.kind === "document" && (media.mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
136
- || fileName.endsWith(".docx"))) {
135
+ if (media?.kind !== "document")
136
+ return undefined;
137
+ // One definition of "a document we can read": the same predicates decide
138
+ // how it is read in `understandAttachmentFile`, so a format added there
139
+ // cannot be downloaded here and then decoded as the wrong thing (r3 C2-03).
140
+ if ((0, docx_text_1.isDocxDocument)(media.mimeType, media.fileName))
137
141
  return "document";
138
- }
139
- if (media?.kind === "document" && (media.mimeType === "application/pdf" || fileName.endsWith(".pdf"))) {
142
+ if (media.mimeType === "application/pdf" || media.fileName?.toLowerCase().endsWith(".pdf"))
140
143
  return "pdf";
141
- }
142
- if (media?.kind === "document" && (media.mimeType?.startsWith("text/")
143
- || ["txt", "md", "markdown", "csv", "tsv", "json", "jsonl", "yaml", "yml", "toml", "ini", "cfg", "conf", "xml", "html", "htm", "log", "rtf"]
144
- .some((extension) => fileName.endsWith(`.${extension}`)))) {
144
+ if ((0, docx_text_1.isTextDocument)(media.mimeType, media.fileName))
145
145
  return "document";
146
- }
147
146
  return undefined;
148
147
  }
149
148
  /**
package/dist/outbound.js CHANGED
@@ -212,7 +212,7 @@ function createOutbound(runtimes) {
212
212
  }
213
213
  // Молчаливый ответ: подпись с токеном молчания означает «ничего не
214
214
  // говорить», и отправлять файл с ним в подписи — тем более.
215
- const mediaCaption = ctx.caption ?? ctx.text;
215
+ let mediaCaption = ctx.caption ?? ctx.text;
216
216
  if (mediaCaption?.trim() && (0, helpers_1.isSilentReplyText)(mediaCaption)) {
217
217
  actionLog.info("clawgram suppressing silent outbound media", {
218
218
  accountId: ctx.accountId,
@@ -227,12 +227,30 @@ function createOutbound(runtimes) {
227
227
  chatId: ctx.to,
228
228
  replyToId: ctx.replyToId,
229
229
  });
230
- // Чего здесь НЕТ намеренно:
231
- // — подавление эха хода (`hadTurnSendJustNow`): у текста дубль стоит
232
- // лишнего сообщения, а у медиа отказ стоит потерянного файла —
233
- // картинку агент готовил, и второй раз она не появится;
234
- // — подавление служебных сообщений ядра в группах: они текстовые,
235
- // медиа-доставка ими не бывает.
230
+ // Служебное уведомление ядра, склеенное с payload, уходило подписью:
231
+ // фильтр телеметрии стоял на трёх текстовых дверях и не стоял здесь
232
+ // (аудит r3 C0-11, «чинить каждый путь»). Файл при этом не теряется —
233
+ // уходит без подписи: картинку агент готовил, второй раз её не будет.
234
+ const captionNotice = mediaCaption?.trim()
235
+ ? (0, system_notice_1.shouldSuppressGroupSystemNotice)({
236
+ targetKind: (0, helpers_1.inferOutboundTargetKind)(ctx.to),
237
+ text: mediaCaption,
238
+ to: ctx.to,
239
+ operatorIds: (0, account_registry_1.operatorIdsFor)(ctx.accountId),
240
+ })
241
+ : undefined;
242
+ if (captionNotice) {
243
+ actionLog.warn("clawgram suppressing system notice in media caption", {
244
+ accountId: ctx.accountId,
245
+ rawTo: ctx.to,
246
+ noticeKind: captionNotice,
247
+ captionLength: mediaCaption.length,
248
+ });
249
+ mediaCaption = undefined;
250
+ }
251
+ // Чего здесь НЕТ намеренно: подавления эха хода (`hadTurnSendJustNow`):
252
+ // у текста дубль стоит лишнего сообщения, а у медиа отказ стоит
253
+ // потерянного файла.
236
254
  const messageThreadId = (0, helpers_2.parseOptionalThreadId)(ctx.threadId);
237
255
  // Same normalization `sendText` does two functions up. Without it the
238
256
  // channel prefix reaches peer resolution and the send throws — which is
@@ -11,8 +11,8 @@ const history_1 = require("./history");
11
11
  * Reading has had a declared scope since 2.x (`readChats`), management has
12
12
  * one (`manageChats`), and sending had none: `send`, `upload-file` and
13
13
  * `react` resolved whatever target the caller named and delivered it. The
14
- * account is a person's own Telegram account, so an injected turn could
15
- * message strangers under the owner's name, or carry a work chat's content
14
+ * account is a real Telegram account, so an injected turn could message
15
+ * strangers under this account's name, or carry a work chat's content
16
16
  * into an attacker's DM one `send` at a time (finding A5-12).
17
17
  *
18
18
  * Two decisions worth stating, because both could reasonably have gone the
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.28.0",
3
+ "version": "2.29.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clawgram",
9
- "version": "2.28.0",
9
+ "version": "2.29.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "json5": "2.2.3",
@@ -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.28.1",
5
+ "version": "2.29.1",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
@@ -468,7 +468,7 @@
468
468
  "items": {
469
469
  "type": "string"
470
470
  },
471
- "description": "Chat ids the account may SEND to (2.18.0): send, upload-file, react and core's delivery path. Same shape as readChats — absent means no restriction, [] denies everything, [\"*\"] allows every chat. A phone number is refused in every configuration, wildcard included: messaging a raw number starts a conversation with someone who never interacted with the account. Without this list an injected turn can message strangers from the owner's personal account or carry a work chat's content into a DM one send at a time."
471
+ "description": "Chat ids the account may SEND to (2.22.0): send, upload-file, react, edit and core's delivery path. Same shape as readChats — absent means no restriction, [] denies everything, [\"*\"] allows every chat. A phone number is refused in every configuration, wildcard included: messaging a raw number starts a conversation with someone who never interacted with the account. Without this list an injected turn can message strangers from this account or carry a work chat's content into a DM one send at a time."
472
472
  },
473
473
  "manageChats": {
474
474
  "type": "array",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.28.1",
3
+ "version": "2.29.1",
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": {
@@ -8,7 +8,8 @@
8
8
  "verify:actions": "node scripts/verify/action-probe.cjs",
9
9
  "build": "tsc -p tsconfig.json",
10
10
  "build:test": "tsc -p tsconfig.test.json",
11
- "test": "npm run build:test && node test/ensure-compiled.mjs && node --test \"dist-test/test/*.test.js\"",
11
+ "test": "node scripts/check-pii.mjs && npm run build:test && node test/ensure-compiled.mjs && node --test \"dist-test/test/*.test.js\"",
12
+ "check:pii": "node scripts/check-pii.mjs",
12
13
  "prepublishOnly": "npm run build && npm test",
13
14
  "release:clawhub": "node scripts/publish-clawhub.mjs",
14
15
  "clawgram-cli": "node dist/clawgram-cli.js",