clawgram 2.4.2 → 2.5.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/channel.js CHANGED
@@ -1,7 +1,16 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.createChannelPlugin = void 0;
4
7
  const core_1 = require("openclaw/plugin-sdk/core");
8
+ const node_os_1 = __importDefault(require("node:os"));
9
+ /** Attachments above this are left unread: a long recording or a huge image is
10
+ * a different conversation from a spoken line or a screenshot, and the
11
+ * transfer is not free. */
12
+ const INBOUND_MEDIA_MAX_BYTES = 25 * 1024 * 1024;
13
+ const media_1 = require("./media");
5
14
  const channel_runtime_1 = require("openclaw/plugin-sdk/channel-runtime");
6
15
  const param_readers_1 = require("openclaw/plugin-sdk/param-readers");
7
16
  const tool_send_1 = require("openclaw/plugin-sdk/tool-send");
@@ -55,7 +64,101 @@ function parseOptionalThreadId(value) {
55
64
  const parsed = Number.parseInt(trimmed, 10);
56
65
  return Number.isFinite(parsed) ? parsed : undefined;
57
66
  }
58
- const createChannelPlugin = (runtimes) => {
67
+ /**
68
+ * Turns an inbound attachment into text the agent can read.
69
+ *
70
+ * The work is deliberately delegated: `runtime.mediaUnderstanding` already
71
+ * knows which backend this installation uses for speech and for images, so
72
+ * the channel stays out of that choice — a local model today, something else
73
+ * tomorrow, without touching this file.
74
+ *
75
+ * Failure is not an error worth dropping the message over. An attachment that
76
+ * could not be read still happened, and the assistant is better off saying
77
+ * "you sent something I could not read" than staying silent, which is
78
+ * indistinguishable from being offline.
79
+ */
80
+ async function readInboundAttachment(params) {
81
+ const media = params.runtime?.mediaUnderstanding;
82
+ const message = params.event?.message;
83
+ if (!media || !message) {
84
+ return undefined;
85
+ }
86
+ let downloaded;
87
+ try {
88
+ downloaded = await (0, media_1.downloadInboundMediaToTempFile)({
89
+ client: params.gram.getClient(),
90
+ message,
91
+ maxBytes: INBOUND_MEDIA_MAX_BYTES,
92
+ tmpDir: node_os_1.default.tmpdir(),
93
+ });
94
+ }
95
+ catch (err) {
96
+ params.log?.info?.("clawgram attachment download failed", {
97
+ accountId: params.accountId,
98
+ chatId: params.chatId,
99
+ messageId: params.messageId,
100
+ error: String(err),
101
+ });
102
+ return undefined;
103
+ }
104
+ if (!downloaded) {
105
+ return undefined;
106
+ }
107
+ try {
108
+ const result = downloaded.understanding === "transcript"
109
+ ? await media.transcribeAudioFile({
110
+ filePath: downloaded.path,
111
+ cfg: params.cfg,
112
+ mime: downloaded.mimeType,
113
+ })
114
+ : await media.describeImageFile({
115
+ filePath: downloaded.path,
116
+ cfg: params.cfg,
117
+ mime: downloaded.mimeType,
118
+ });
119
+ const read = typeof result?.text === "string" ? result.text.trim() : "";
120
+ if (!read) {
121
+ params.log?.info?.("clawgram attachment read empty", {
122
+ accountId: params.accountId,
123
+ chatId: params.chatId,
124
+ messageId: params.messageId,
125
+ understanding: downloaded.understanding,
126
+ });
127
+ return undefined;
128
+ }
129
+ params.log?.info?.("clawgram attachment read", {
130
+ accountId: params.accountId,
131
+ chatId: params.chatId,
132
+ messageId: params.messageId,
133
+ understanding: downloaded.understanding,
134
+ characters: read.length,
135
+ });
136
+ return { text: read, understanding: downloaded.understanding };
137
+ }
138
+ catch (err) {
139
+ params.log?.info?.("clawgram attachment read failed", {
140
+ accountId: params.accountId,
141
+ chatId: params.chatId,
142
+ messageId: params.messageId,
143
+ understanding: downloaded.understanding,
144
+ error: String(err),
145
+ });
146
+ return undefined;
147
+ }
148
+ finally {
149
+ void (async () => {
150
+ try {
151
+ const { rm } = await import("node:fs/promises");
152
+ const { dirname } = await import("node:path");
153
+ await rm(dirname(downloaded.path), { recursive: true, force: true });
154
+ }
155
+ catch {
156
+ // Leaving a temp file behind is not worth failing a delivered message.
157
+ }
158
+ })();
159
+ }
160
+ }
161
+ const createChannelPlugin = (runtimes, pluginRuntime) => {
59
162
  const resolveRuntimeAccountId = (cfg, preferred) => {
60
163
  const configured = (0, helpers_1.resolveConfiguredAccountId)(cfg, preferred);
61
164
  if (configured && runtimes.has(configured)) {
@@ -269,7 +372,28 @@ const createChannelPlugin = (runtimes) => {
269
372
  });
270
373
  return;
271
374
  }
272
- const text = normalized.text?.trim();
375
+ let text = normalized.text?.trim();
376
+ // An attachment carries no text of its own, and dropping it as
377
+ // "empty" is how the assistant used to go silent on being spoken
378
+ // to or shown something. Read it into the body instead: for a
379
+ // voice note and a screenshot alike, the attachment *is* the
380
+ // message. A caption is kept and the reading appended, because
381
+ // "look at this" plus the picture is one thought, not two.
382
+ const attachment = await readInboundAttachment({
383
+ gram,
384
+ event,
385
+ cfg,
386
+ runtime: pluginRuntime,
387
+ log,
388
+ accountId,
389
+ chatId: normalized.chatId,
390
+ messageId: normalized.messageId,
391
+ });
392
+ if (attachment) {
393
+ const marker = attachment.understanding === "transcript" ? "голосовое" : "изображение";
394
+ const read = `[${marker}] ${attachment.text}`;
395
+ text = text ? `${text}\n\n${read}` : read;
396
+ }
273
397
  if (!text) {
274
398
  log?.info?.("clawgram skipping empty inbound text", {
275
399
  accountId,
@@ -1293,14 +1417,22 @@ const createChannelPlugin = (runtimes) => {
1293
1417
  },
1294
1418
  outbound: {
1295
1419
  // Core's agent-delivery path (`--deliver`, subagent announces) calls this
1296
- // with `to: undefined` whenever a delivery has no explicit target and the
1297
- // session route yielded none — and it does not catch a rejection from
1298
- // here. A throw is therefore an unhandled rejection that takes down the
1299
- // entire gateway process, which is exactly what happened on 2026-08-06
1300
- // (systemd: Main process exited, status=1). The contract, read from
1301
- // core's resolveOutboundTargetWithPlugin: answer { ok: false, error } for
1302
- // anything unresolvable. Never throw.
1303
- async resolveTarget(ctx) {
1420
+ // hook under three constraints, all learned live on 2026-08-06:
1421
+ //
1422
+ // - `to` may be undefined (no explicit target, session route yielded
1423
+ // none), and a rejection is NOT caught: a throw here is an unhandled
1424
+ // rejection that takes down the entire gateway process.
1425
+ // - `resolveAgentDeliveryPlanWithSessionRoute` calls it WITHOUT await.
1426
+ // An async hook hands core a Promise, `promise.ok` reads undefined and
1427
+ // the error branch dereferences `promise.error.message` — the crash
1428
+ // every subagent announce died on. The hook must return a plain value;
1429
+ // the call sites that do await are unaffected, await of a value works.
1430
+ // - In a not-ok result core reads `error.message`, so the error must be
1431
+ // Error-like, not a bare string.
1432
+ //
1433
+ // Peer resolution deliberately does not happen here: `sendText` resolves
1434
+ // the peer itself, and doing it here would force the hook async again.
1435
+ resolveTarget(ctx) {
1304
1436
  try {
1305
1437
  const raw = typeof ctx.to === "string" ? ctx.to.trim() : "";
1306
1438
  actionLog.info("clawgram outbound resolveTarget", {
@@ -1308,21 +1440,12 @@ const createChannelPlugin = (runtimes) => {
1308
1440
  rawTo: raw || null,
1309
1441
  });
1310
1442
  if (!raw) {
1311
- return { ok: false, error: "clawgram: no delivery target — pass `to` or use a session with a bound chat" };
1312
- }
1313
- const gram = runtimes.get(ctx.accountId);
1314
- if (!gram) {
1315
- return { ok: false, error: `clawgram: runtime not found for account ${ctx.accountId}` };
1443
+ return { ok: false, error: new Error("clawgram: no delivery target — pass `to` or use a session with a bound chat") };
1316
1444
  }
1317
- const targetKind = (0, helpers_1.inferOutboundTargetKind)(raw);
1318
- const target = (0, helpers_1.normalizeOutboundTarget)(raw);
1319
- return {
1320
- ok: true,
1321
- to: (await gram.resolvePeer(target, { kind: targetKind })).chatId ?? target,
1322
- };
1445
+ return { ok: true, to: (0, helpers_1.normalizeOutboundTarget)(raw) };
1323
1446
  }
1324
1447
  catch (err) {
1325
- return { ok: false, error: `clawgram: target resolution failed: ${String(err)}` };
1448
+ return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
1326
1449
  }
1327
1450
  },
1328
1451
  async sendText(ctx) {
package/dist/index.js CHANGED
@@ -14,7 +14,9 @@ const plugin = {
14
14
  commands: (0, cli_1.getTelegramUserbotCliDescriptors)().map((entry) => entry.name),
15
15
  descriptors: (0, cli_1.getTelegramUserbotCliDescriptors)()
16
16
  });
17
- api.registerChannel({ plugin: (0, channel_1.createChannelPlugin)(runtimes) });
17
+ // api.runtime carries the media-understanding pipeline; without it an
18
+ // inbound voice note has nothing to be turned into words with.
19
+ api.registerChannel({ plugin: (0, channel_1.createChannelPlugin)(runtimes, api?.runtime) });
18
20
  }
19
21
  };
20
22
  exports.default = plugin;
package/dist/media.js CHANGED
@@ -13,6 +13,8 @@
13
13
  */
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.describeMedia = describeMedia;
16
+ exports.inboundMediaUnderstanding = inboundMediaUnderstanding;
17
+ exports.downloadInboundMediaToTempFile = downloadInboundMediaToTempFile;
16
18
  /**
17
19
  * GramJS carries numbers as `big-integer` objects as often as native numbers —
18
20
  * the same shape that once made `senderId` silently undefined. Anything that
@@ -98,3 +100,70 @@ function describeMedia(media) {
98
100
  // as "something was attached" — a blank message is the failure being fixed.
99
101
  return { kind: "other", telegramType: className };
100
102
  }
103
+ /**
104
+ * Decides whether an attachment is worth fetching, and what reading it means.
105
+ *
106
+ * Voice notes and images are the two kinds whose bytes *are* the message:
107
+ * dropping them leaves the assistant silent on being spoken to or shown
108
+ * something. Other attachments keep the old treatment — metadata only —
109
+ * because "spec.pdf, 240 KB" already tells a reader what happened, and
110
+ * fetching every document would be a different feature with different costs.
111
+ */
112
+ function inboundMediaUnderstanding(media) {
113
+ if (!media)
114
+ return undefined;
115
+ if (media.kind === "voice" || media.kind === "audio")
116
+ return "transcript";
117
+ if (media.kind === "photo")
118
+ return "description";
119
+ // A document can be an image sent "as file" — Telegram keeps the pixels,
120
+ // only the envelope differs, so read it rather than announce it.
121
+ if (media.kind === "document" && media.mimeType?.startsWith("image/"))
122
+ return "description";
123
+ return undefined;
124
+ }
125
+ /**
126
+ * Downloads an inbound attachment to a temporary file.
127
+ *
128
+ * Returns the path, or undefined when the attachment is not one this channel
129
+ * reads, or is too large to be worth the transfer. The caller owns the file
130
+ * and is responsible for removing it.
131
+ */
132
+ async function downloadInboundMediaToTempFile(params) {
133
+ const described = describeMedia(params.message?.media);
134
+ const understanding = inboundMediaUnderstanding(described);
135
+ if (!described || !understanding) {
136
+ return undefined;
137
+ }
138
+ // A cap belongs here rather than in the caller: an oversized attachment
139
+ // should be reported as such, not fetched and then discarded after the
140
+ // transfer cost.
141
+ if (typeof described.size === "number" && described.size > params.maxBytes) {
142
+ return undefined;
143
+ }
144
+ const buffer = await params.client.downloadMedia(params.message, {});
145
+ if (!buffer || !(buffer instanceof Buffer) || buffer.length === 0) {
146
+ return undefined;
147
+ }
148
+ const extension = extensionFor(described, understanding);
149
+ const { mkdtemp, writeFile } = await import("node:fs/promises");
150
+ const { join } = await import("node:path");
151
+ const dir = await mkdtemp(join(params.tmpDir, "clawgram-media-"));
152
+ const path = join(dir, `attachment.${extension}`);
153
+ await writeFile(path, buffer);
154
+ return { path, mimeType: described.mimeType, understanding };
155
+ }
156
+ function extensionFor(media, understanding) {
157
+ if (understanding === "description") {
158
+ if (media.mimeType === "image/png")
159
+ return "png";
160
+ if (media.mimeType === "image/webp")
161
+ return "webp";
162
+ return "jpg";
163
+ }
164
+ if (media.mimeType === "audio/mpeg")
165
+ return "mp3";
166
+ if (media.mimeType === "audio/mp4")
167
+ return "m4a";
168
+ return "ogg";
169
+ }
@@ -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.4.2",
5
+ "version": "2.5.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.4.2",
3
+ "version": "2.5.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": {