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.
@@ -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
- // этом хосте живёт ещё и раннер деплоя. Каталог состояния OpenClaw
123
- // принадлежит агенту; если он не задан, остаётся /tmp — но права
124
- // 0700/0600 ставятся в любом случае (A5-13).
125
- // Каталог состояния принадлежит агенту; при явно заданном
126
- // OPENCLAW_STATE_DIR вложения не покидают его.
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, mkdir } = await import("node:fs/promises");
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
- return input.senderId;
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
  }
@@ -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: senderLabel,
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: senderLabel,
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 { mkdir, writeFile, chmod } = await import("node:fs/promises");
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 mkdir(params.dir, { recursive: true, mode: 0o700 });
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 });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.26.1",
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.26.1",
9
+ "version": "2.28.0",
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.26.1",
5
+ "version": "2.28.0",
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.26.1",
3
+ "version": "2.28.0",
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": {