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 +2 -1
- package/dist/actions-read.js +110 -93
- package/dist/actions-send.js +62 -1
- package/dist/actions.js +14 -0
- package/dist/channel.js +3 -0
- package/dist/edits.js +46 -0
- package/dist/gramjs-client.js +30 -0
- package/dist/group-reply-address.js +1 -1
- package/dist/inbound-pipeline.js +1 -1
- package/dist/media.js +9 -10
- package/dist/outbound.js +25 -7
- package/dist/send-scope.js +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/openclaw.plugin.json +2 -2
- package/package.json +3 -2
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.
|
|
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 |
|
package/dist/actions-read.js
CHANGED
|
@@ -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 (
|
|
138
|
-
|
|
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
|
-
|
|
148
|
+
const { mkdtemp } = await import("node:fs/promises");
|
|
149
|
+
fetchDir = await mkdtemp(node_path_1.default.join(mediaRoot, "clawgram-media-"));
|
|
143
150
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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:
|
|
236
|
+
ok: true,
|
|
179
237
|
accountId: fetchAccountId,
|
|
180
238
|
chatId: fetchChatId,
|
|
181
239
|
messageId: String(fetchParams.messageId),
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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.
|
package/dist/actions-send.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/gramjs-client.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
}
|
package/dist/inbound-pipeline.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
package/dist/send-scope.js
CHANGED
|
@@ -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
|
|
15
|
-
*
|
|
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
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawgram",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
9
|
+
"version": "2.29.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"json5": "2.2.3",
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|