clawgram 2.26.1 → 2.28.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.
- package/dist/actions-read.js +12 -8
- package/dist/group-reply-address.js +6 -1
- package/dist/inbound-pipeline.js +75 -8
- package/dist/media.js +77 -3
- package/npm-shrinkwrap.json +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/actions-read.js
CHANGED
|
@@ -119,19 +119,23 @@ async function handleReadAction(ctx) {
|
|
|
119
119
|
// that path would pull the file out from under an earlier `both`
|
|
120
120
|
// fetch of the same message that handed the caller a path.
|
|
121
121
|
// Не общий /tmp: там файлы видит каждый локальный пользователь, а на
|
|
122
|
-
// этом хосте живёт ещё и раннер
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
122
|
+
// этом хосте живёт ещё и раннер деплоя (A5-13). При заданном
|
|
123
|
+
// OPENCLAW_STATE_DIR вложения не покидают каталог состояния; иначе
|
|
124
|
+
// корень лежит в /tmp и несёт uid процесса в имени.
|
|
125
|
+
//
|
|
126
|
+
// Прежде это был `/tmp/clawgram-fetched` — постоянное имя в каталоге,
|
|
127
|
+
// который делят все пользователи хоста. Когда агент переехал на свою
|
|
128
|
+
// учётку, имя осталось занято каталогом прежней, и каждая картинка с
|
|
129
|
+
// 05.09 по 07.09.2026 падала с `EACCES`. Имя с uid разводит учётки, а
|
|
130
|
+
// `ensurePrivateDir` проверяет, что каталог действительно наш и закрыт.
|
|
127
131
|
const mediaRoot = process.env.OPENCLAW_STATE_DIR?.trim()
|
|
128
132
|
? node_path_1.default.join((0, state_dir_1.resolveStateDir)(), "tmp")
|
|
129
|
-
: node_os_1.default.tmpdir();
|
|
133
|
+
: node_path_1.default.join(node_os_1.default.tmpdir(), `clawgram-${typeof process.getuid === "function" ? process.getuid() : "user"}`);
|
|
134
|
+
await (0, media_1.ensurePrivateDir)(mediaRoot);
|
|
130
135
|
const sharedFetchDir = node_path_1.default.join(mediaRoot, "clawgram-fetched");
|
|
131
136
|
let fetchDir = sharedFetchDir;
|
|
132
137
|
if (fetchParams.mode === "read") {
|
|
133
|
-
const { mkdtemp
|
|
134
|
-
await mkdir(mediaRoot, { recursive: true, mode: 0o700 });
|
|
138
|
+
const { mkdtemp } = await import("node:fs/promises");
|
|
135
139
|
fetchDir = await mkdtemp(node_path_1.default.join(mediaRoot, "clawgram-media-"));
|
|
136
140
|
}
|
|
137
141
|
else {
|
|
@@ -79,5 +79,10 @@ function buildGroupReplyAddress(input) {
|
|
|
79
79
|
if (display && display !== "Telegram") {
|
|
80
80
|
return display;
|
|
81
81
|
}
|
|
82
|
-
|
|
82
|
+
// No handle and no name means no greeting, not a numeric one. The id
|
|
83
|
+
// used to be the fallback, and in a basic group — where GramJS attaches
|
|
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
|
|
86
|
+
// telegram id and reads it as a malfunction.
|
|
87
|
+
return undefined;
|
|
83
88
|
}
|
package/dist/inbound-pipeline.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.visibleReplyText = visibleReplyText;
|
|
4
4
|
exports.handleInboundEvent = handleInboundEvent;
|
|
5
|
+
exports.agentFacingGroupBody = agentFacingGroupBody;
|
|
5
6
|
// Входящий контур: одно событие Telegram от нормализации до ответа.
|
|
6
7
|
//
|
|
7
8
|
// Вынесено из `channel.ts` — 856 строк внутри `gateway.startAccount`, самый
|
|
@@ -252,6 +253,13 @@ async function handleInboundEvent(event, ctx) {
|
|
|
252
253
|
senderId: inboundSenderId,
|
|
253
254
|
senderUsername: normalized.senderUsername,
|
|
254
255
|
});
|
|
256
|
+
// The name of a direct-message sender who may reach the agent. The gate
|
|
257
|
+
// above stays where B5-04 put it — a blocked sender still costs no
|
|
258
|
+
// call. A group sender is looked up later, after the mention gate, for
|
|
259
|
+
// the same reason (see the group branch).
|
|
260
|
+
if (normalized.chatType === "direct" && senderMayReachAgent) {
|
|
261
|
+
await resolveNamelessSender(normalized, rawMessage, client);
|
|
262
|
+
}
|
|
255
263
|
// An attachment carries no text of its own, and dropping it as
|
|
256
264
|
// "empty" is how the assistant used to go silent on being spoken
|
|
257
265
|
// to or shown something. Read it into the body instead: for a
|
|
@@ -485,16 +493,32 @@ async function handleInboundEvent(event, ctx) {
|
|
|
485
493
|
});
|
|
486
494
|
return;
|
|
487
495
|
}
|
|
496
|
+
// Who is speaking, resolved only now — past the group gate and the
|
|
497
|
+
// mention gate, so a message the agent will not even read costs no
|
|
498
|
+
// call (B5-04). GramJS attaches `_sender` only from its entity cache,
|
|
499
|
+
// and in a basic group (or after a restart) that cache is empty: the
|
|
500
|
+
// turn then had nothing but the numeric id, the reply greeting fell
|
|
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
|
|
503
|
+
// the channel would prepend is also what the agent reads, so the
|
|
504
|
+
// model cannot greet «Вася Ш.» while the channel greets «@vasya».
|
|
505
|
+
await resolveNamelessSender(normalized, rawMessage, client);
|
|
506
|
+
const groupSenderLabel = normalized.senderDisplay || normalized.senderUsername || senderId;
|
|
507
|
+
const groupReplyAddress = (0, group_reply_address_1.buildGroupReplyAddress)({
|
|
508
|
+
senderUsername: normalized.senderUsername,
|
|
509
|
+
senderDisplay: normalized.senderDisplay,
|
|
510
|
+
senderId,
|
|
511
|
+
});
|
|
488
512
|
const { storePath, body } = buildEnvelope({
|
|
489
513
|
channel: "Telegram",
|
|
490
|
-
from:
|
|
514
|
+
from: groupSenderLabel,
|
|
491
515
|
body: text,
|
|
492
516
|
timestamp: normalized.timestamp,
|
|
493
517
|
});
|
|
494
518
|
const conversationRouteTarget = (0, helpers_1.buildConversationTarget)(normalized.chatId);
|
|
495
519
|
const ctxPayload = channelRuntime.reply.finalizeInboundContext({
|
|
496
520
|
Body: body,
|
|
497
|
-
BodyForAgent: text,
|
|
521
|
+
BodyForAgent: agentFacingGroupBody({ address: groupReplyAddress, senderId, text }),
|
|
498
522
|
RawBody: text,
|
|
499
523
|
CommandBody: text,
|
|
500
524
|
From: conversationRouteTarget,
|
|
@@ -502,7 +526,7 @@ async function handleInboundEvent(event, ctx) {
|
|
|
502
526
|
SessionKey: route.sessionKey,
|
|
503
527
|
AccountId: route.accountId ?? accountId,
|
|
504
528
|
ChatType: "group",
|
|
505
|
-
ConversationLabel:
|
|
529
|
+
ConversationLabel: groupSenderLabel,
|
|
506
530
|
SenderId: senderId,
|
|
507
531
|
SenderUsername: normalized.senderUsername,
|
|
508
532
|
SenderName: normalized.senderDisplay,
|
|
@@ -535,11 +559,6 @@ async function handleInboundEvent(event, ctx) {
|
|
|
535
559
|
OriginatingChannel: "clawgram",
|
|
536
560
|
OriginatingTo: conversationRouteTarget,
|
|
537
561
|
});
|
|
538
|
-
const groupReplyAddress = (0, group_reply_address_1.buildGroupReplyAddress)({
|
|
539
|
-
senderUsername: normalized.senderUsername,
|
|
540
|
-
senderDisplay: normalized.senderDisplay,
|
|
541
|
-
senderId,
|
|
542
|
-
});
|
|
543
562
|
(0, group_reply_address_1.rememberGroupReplyAddress)({
|
|
544
563
|
accountId: route.accountId ?? accountId,
|
|
545
564
|
chatId: normalized.chatId,
|
|
@@ -897,3 +916,51 @@ async function handleInboundEvent(event, ctx) {
|
|
|
897
916
|
});
|
|
898
917
|
}
|
|
899
918
|
}
|
|
919
|
+
/**
|
|
920
|
+
* A sender who arrived without a name or handle gets one profile lookup.
|
|
921
|
+
*
|
|
922
|
+
* GramJS attaches `_sender` only from its in-memory entity cache; a basic
|
|
923
|
+
* group's update carries no users, so after a restart the cache is empty
|
|
924
|
+
* until something else (a `participants` read) fills it. The lookup may
|
|
925
|
+
* therefore still come back empty — then the sender stays nameless, the
|
|
926
|
+
* greeting is omitted and the agent reads `id:<n>`. Callers decide *when*
|
|
927
|
+
* this runs: after every gate, never for traffic the agent will not read.
|
|
928
|
+
*/
|
|
929
|
+
async function resolveNamelessSender(normalized, rawMessage, client) {
|
|
930
|
+
if (!normalized.senderId || normalized.senderDisplay || normalized.senderUsername) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const profile = await (0, helpers_1.resolveSenderProfileWithTimeout)(rawMessage, {
|
|
934
|
+
senderId: normalized.senderId,
|
|
935
|
+
client,
|
|
936
|
+
}, 1500);
|
|
937
|
+
if (profile.username) {
|
|
938
|
+
normalized.senderUsername = profile.username;
|
|
939
|
+
}
|
|
940
|
+
// `toDisplayName` answers "Telegram" when it knows nothing; that is not
|
|
941
|
+
// a name and must not become one here.
|
|
942
|
+
if (profile.display && profile.display !== "Telegram") {
|
|
943
|
+
normalized.senderDisplay = profile.display;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* What the agent reads for a group message: `Адрес: текст`.
|
|
948
|
+
*
|
|
949
|
+
* Until 2.27.0 the turn received the bare text. Core's own Telegram channel
|
|
950
|
+
* prefixes the sender for groups (`formatInboundEnvelope`), and without that
|
|
951
|
+
* the agent could tell speakers apart only by the numeric id in metadata —
|
|
952
|
+
* which is exactly what it then used as an address. The prefix is the very
|
|
953
|
+
* address the channel would prepend to a reply (`buildGroupReplyAddress`),
|
|
954
|
+
* so what the model addresses and what the channel greets never differ —
|
|
955
|
+
* a display name in the body with a handle in the greeting would have
|
|
956
|
+
* produced «@vasya, Вася Ш., готово». No address known: `id:<n>`, marked
|
|
957
|
+
* so it is never mistaken for a name.
|
|
958
|
+
*/
|
|
959
|
+
function agentFacingGroupBody(input) {
|
|
960
|
+
const address = input.address?.trim();
|
|
961
|
+
if (address) {
|
|
962
|
+
return `${address}: ${input.text}`;
|
|
963
|
+
}
|
|
964
|
+
const id = input.senderId !== undefined ? String(input.senderId).trim() : "";
|
|
965
|
+
return id ? `id:${id}: ${input.text}` : input.text;
|
|
966
|
+
}
|
package/dist/media.js
CHANGED
|
@@ -18,6 +18,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
18
18
|
exports.describeMedia = describeMedia;
|
|
19
19
|
exports.inboundMediaUnderstanding = inboundMediaUnderstanding;
|
|
20
20
|
exports.downloadInboundMediaToTempFile = downloadInboundMediaToTempFile;
|
|
21
|
+
exports.describePrivateDirProblem = describePrivateDirProblem;
|
|
22
|
+
exports.ensurePrivateDir = ensurePrivateDir;
|
|
21
23
|
exports.downloadMessageMediaToFile = downloadMessageMediaToFile;
|
|
22
24
|
exports.pruneFetchedMedia = pruneFetchedMedia;
|
|
23
25
|
exports.isLocalMediaPath = isLocalMediaPath;
|
|
@@ -153,6 +155,79 @@ async function downloadInboundMediaToTempFile(params) {
|
|
|
153
155
|
fileNameFor: ({ extension }) => `attachment.${extension}`,
|
|
154
156
|
});
|
|
155
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Why a directory that merely exists is not good enough.
|
|
160
|
+
*
|
|
161
|
+
* `mkdir(..., { recursive: true })` is a no-op on an existing directory and
|
|
162
|
+
* `chmod` on a directory owned by somebody else fails — and that failure used
|
|
163
|
+
* to be swallowed. The write then hit `EACCES` and the agent was handed the
|
|
164
|
+
* bare errno with nothing to act on.
|
|
165
|
+
*
|
|
166
|
+
* That is not hypothetical: the shared fetch directory had a fixed name in
|
|
167
|
+
* world-writable `/tmp`, and when the agent moved to its own account
|
|
168
|
+
* (04.09.2026) the old account's leftover kept the name. Every picture sent
|
|
169
|
+
* to the agent failed from 05.09 to 07.09 with
|
|
170
|
+
* `EACCES: permission denied, open '/tmp/clawgram-fetched/…'`, and the agent
|
|
171
|
+
* told its owner its "disk access was not restored" — the closest reading it
|
|
172
|
+
* could make of an errno.
|
|
173
|
+
*
|
|
174
|
+
* The same shape is a way in, not only an accident: any local user (this host
|
|
175
|
+
* also runs a deploy runner) could pre-create that predictable path and read
|
|
176
|
+
* every attachment written into it. So the check is ownership and mode, not
|
|
177
|
+
* existence.
|
|
178
|
+
*/
|
|
179
|
+
function describePrivateDirProblem(input) {
|
|
180
|
+
if (input.isSymbolicLink) {
|
|
181
|
+
return `clawgram: ${input.path} is a symlink — refusing to write attachments through it`;
|
|
182
|
+
}
|
|
183
|
+
if (!input.isDirectory) {
|
|
184
|
+
return `clawgram: ${input.path} exists and is not a directory`;
|
|
185
|
+
}
|
|
186
|
+
if (input.selfUid !== undefined && input.uid !== input.selfUid) {
|
|
187
|
+
return `clawgram: ${input.path} belongs to uid ${input.uid}, this process runs as ${input.selfUid}`
|
|
188
|
+
+ " — a leftover from another account is holding the path; remove it or give it to this account";
|
|
189
|
+
}
|
|
190
|
+
const bits = input.mode & 0o777;
|
|
191
|
+
if ((bits & 0o077) !== 0) {
|
|
192
|
+
return `clawgram: ${input.path} is readable beyond this account (mode ${bits.toString(8)})`;
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Creates the directory, makes it private, and proves it — see above.
|
|
198
|
+
*/
|
|
199
|
+
async function ensurePrivateDir(dir) {
|
|
200
|
+
const { mkdir, chmod, lstat } = await import("node:fs/promises");
|
|
201
|
+
// `EEXIST` means something already holds the name — a file, a symlink,
|
|
202
|
+
// another account's directory. The check below says which, and that is
|
|
203
|
+
// the whole point; an errno is what the agent could not act on. Any other
|
|
204
|
+
// failure (no space, read-only mount) is still the caller's problem.
|
|
205
|
+
await mkdir(dir, { recursive: true, mode: 0o700 }).catch((err) => {
|
|
206
|
+
if (err?.code !== "EEXIST") {
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
// Only tighten what is already our directory: `chmod` on a stray file
|
|
211
|
+
// would change a mode that is none of our business, and on somebody
|
|
212
|
+
// else's directory it fails anyway — silently, which is how this stayed
|
|
213
|
+
// invisible for three days.
|
|
214
|
+
const found = await lstat(dir);
|
|
215
|
+
if (found.isDirectory()) {
|
|
216
|
+
await chmod(dir, 0o700).catch(() => undefined);
|
|
217
|
+
}
|
|
218
|
+
const stats = await lstat(dir);
|
|
219
|
+
const problem = describePrivateDirProblem({
|
|
220
|
+
path: dir,
|
|
221
|
+
isDirectory: stats.isDirectory(),
|
|
222
|
+
isSymbolicLink: stats.isSymbolicLink(),
|
|
223
|
+
uid: stats.uid,
|
|
224
|
+
mode: stats.mode,
|
|
225
|
+
selfUid: typeof process.getuid === "function" ? process.getuid() : undefined,
|
|
226
|
+
});
|
|
227
|
+
if (problem) {
|
|
228
|
+
throw new Error(problem);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
156
231
|
/**
|
|
157
232
|
* Downloads an attachment into a directory the caller names and owns.
|
|
158
233
|
*
|
|
@@ -178,7 +253,7 @@ async function downloadMessageMediaToFile(params) {
|
|
|
178
253
|
if (!buffer || !(buffer instanceof Buffer) || buffer.length === 0) {
|
|
179
254
|
return undefined;
|
|
180
255
|
}
|
|
181
|
-
const {
|
|
256
|
+
const { writeFile, chmod } = await import("node:fs/promises");
|
|
182
257
|
const { join } = await import("node:path");
|
|
183
258
|
// Личная переписка на диске: каталог и файл принадлежат только агенту.
|
|
184
259
|
// По умолчанию (umask 022) выходило 0755/0644, то есть вложения из личных
|
|
@@ -186,8 +261,7 @@ async function downloadMessageMediaToFile(params) {
|
|
|
186
261
|
// gitlab-runner (A5-13). `mode` у mkdir и writeFile маскируется umask,
|
|
187
262
|
// поэтому права выставляются отдельным chmod, как это уже делается для
|
|
188
263
|
// конфига в update-config.ts.
|
|
189
|
-
await
|
|
190
|
-
await chmod(params.dir, 0o700).catch(() => undefined);
|
|
264
|
+
await ensurePrivateDir(params.dir);
|
|
191
265
|
const extension = extensionFor(described, understanding);
|
|
192
266
|
const path = join(params.dir, params.fileNameFor({ media: described, extension }));
|
|
193
267
|
await writeFile(path, buffer, { mode: 0o600 });
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawgram",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.28.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "clawgram",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.28.0",
|
|
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.28.0",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
package/package.json
CHANGED