clawgram 2.4.3 → 2.5.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/dist/channel.js CHANGED
@@ -1,7 +1,18 @@
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
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const node_fs_1 = require("node:fs");
11
+ /** Attachments above this are left unread: a long recording or a huge image is
12
+ * a different conversation from a spoken line or a screenshot, and the
13
+ * transfer is not free. */
14
+ const INBOUND_MEDIA_MAX_BYTES = 25 * 1024 * 1024;
15
+ const media_1 = require("./media");
5
16
  const channel_runtime_1 = require("openclaw/plugin-sdk/channel-runtime");
6
17
  const param_readers_1 = require("openclaw/plugin-sdk/param-readers");
7
18
  const tool_send_1 = require("openclaw/plugin-sdk/tool-send");
@@ -55,7 +66,121 @@ function parseOptionalThreadId(value) {
55
66
  const parsed = Number.parseInt(trimmed, 10);
56
67
  return Number.isFinite(parsed) ? parsed : undefined;
57
68
  }
58
- const createChannelPlugin = (runtimes) => {
69
+ /**
70
+ * Turns an inbound attachment into text the agent can read.
71
+ *
72
+ * The work is deliberately delegated: `runtime.mediaUnderstanding` already
73
+ * knows which backend this installation uses for speech and for images, so
74
+ * the channel stays out of that choice — a local model today, something else
75
+ * tomorrow, without touching this file.
76
+ *
77
+ * Failure is not an error worth dropping the message over. An attachment that
78
+ * could not be read still happened, and the assistant is better off saying
79
+ * "you sent something I could not read" than staying silent, which is
80
+ * indistinguishable from being offline.
81
+ */
82
+ /**
83
+ * Locates the agent directory that image understanding needs.
84
+ *
85
+ * Image models are called with the agent's own credentials, so the pipeline
86
+ * refuses to run without this path — audio does not need it, which is why
87
+ * voice notes worked before images did. The platform exposes no resolver to
88
+ * plugins, so the documented layout is reconstructed here and checked before
89
+ * use: a wrong guess would fail the read anyway, and returning undefined lets
90
+ * the caller degrade instead of throwing.
91
+ */
92
+ function resolveAgentDirForMedia(cfg) {
93
+ const stateDir = typeof process.env.OPENCLAW_STATE_DIR === "string" && process.env.OPENCLAW_STATE_DIR.trim()
94
+ ? process.env.OPENCLAW_STATE_DIR.trim()
95
+ : node_path_1.default.join(node_os_1.default.homedir(), ".openclaw");
96
+ const configuredId = cfg?.agents?.defaults?.id;
97
+ const agentId = typeof configuredId === "string" && configuredId.trim() ? configuredId.trim() : "main";
98
+ const dir = node_path_1.default.join(stateDir, "agents", agentId, "agent");
99
+ return (0, node_fs_1.existsSync)(dir) ? dir : undefined;
100
+ }
101
+ async function readInboundAttachment(params) {
102
+ const media = params.runtime?.mediaUnderstanding;
103
+ const message = params.event?.message;
104
+ if (!media || !message) {
105
+ return undefined;
106
+ }
107
+ let downloaded;
108
+ try {
109
+ downloaded = await (0, media_1.downloadInboundMediaToTempFile)({
110
+ client: params.gram.getClient(),
111
+ message,
112
+ maxBytes: INBOUND_MEDIA_MAX_BYTES,
113
+ tmpDir: node_os_1.default.tmpdir(),
114
+ });
115
+ }
116
+ catch (err) {
117
+ params.log?.info?.("clawgram attachment download failed", {
118
+ accountId: params.accountId,
119
+ chatId: params.chatId,
120
+ messageId: params.messageId,
121
+ error: String(err),
122
+ });
123
+ return undefined;
124
+ }
125
+ if (!downloaded) {
126
+ return undefined;
127
+ }
128
+ try {
129
+ const result = downloaded.understanding === "transcript"
130
+ ? await media.transcribeAudioFile({
131
+ filePath: downloaded.path,
132
+ cfg: params.cfg,
133
+ mime: downloaded.mimeType,
134
+ })
135
+ : await media.describeImageFile({
136
+ filePath: downloaded.path,
137
+ cfg: params.cfg,
138
+ mime: downloaded.mimeType,
139
+ agentDir: resolveAgentDirForMedia(params.cfg),
140
+ });
141
+ const read = typeof result?.text === "string" ? result.text.trim() : "";
142
+ if (!read) {
143
+ params.log?.info?.("clawgram attachment read empty", {
144
+ accountId: params.accountId,
145
+ chatId: params.chatId,
146
+ messageId: params.messageId,
147
+ understanding: downloaded.understanding,
148
+ });
149
+ return undefined;
150
+ }
151
+ params.log?.info?.("clawgram attachment read", {
152
+ accountId: params.accountId,
153
+ chatId: params.chatId,
154
+ messageId: params.messageId,
155
+ understanding: downloaded.understanding,
156
+ characters: read.length,
157
+ });
158
+ return { text: read, understanding: downloaded.understanding };
159
+ }
160
+ catch (err) {
161
+ params.log?.info?.("clawgram attachment read failed", {
162
+ accountId: params.accountId,
163
+ chatId: params.chatId,
164
+ messageId: params.messageId,
165
+ understanding: downloaded.understanding,
166
+ error: String(err),
167
+ });
168
+ return undefined;
169
+ }
170
+ finally {
171
+ void (async () => {
172
+ try {
173
+ const { rm } = await import("node:fs/promises");
174
+ const { dirname } = await import("node:path");
175
+ await rm(dirname(downloaded.path), { recursive: true, force: true });
176
+ }
177
+ catch {
178
+ // Leaving a temp file behind is not worth failing a delivered message.
179
+ }
180
+ })();
181
+ }
182
+ }
183
+ const createChannelPlugin = (runtimes, pluginRuntime) => {
59
184
  const resolveRuntimeAccountId = (cfg, preferred) => {
60
185
  const configured = (0, helpers_1.resolveConfiguredAccountId)(cfg, preferred);
61
186
  if (configured && runtimes.has(configured)) {
@@ -269,7 +394,28 @@ const createChannelPlugin = (runtimes) => {
269
394
  });
270
395
  return;
271
396
  }
272
- const text = normalized.text?.trim();
397
+ let text = normalized.text?.trim();
398
+ // An attachment carries no text of its own, and dropping it as
399
+ // "empty" is how the assistant used to go silent on being spoken
400
+ // to or shown something. Read it into the body instead: for a
401
+ // voice note and a screenshot alike, the attachment *is* the
402
+ // message. A caption is kept and the reading appended, because
403
+ // "look at this" plus the picture is one thought, not two.
404
+ const attachment = await readInboundAttachment({
405
+ gram,
406
+ event,
407
+ cfg,
408
+ runtime: pluginRuntime,
409
+ log,
410
+ accountId,
411
+ chatId: normalized.chatId,
412
+ messageId: normalized.messageId,
413
+ });
414
+ if (attachment) {
415
+ const marker = attachment.understanding === "transcript" ? "голосовое" : "изображение";
416
+ const read = `[${marker}] ${attachment.text}`;
417
+ text = text ? `${text}\n\n${read}` : read;
418
+ }
273
419
  if (!text) {
274
420
  log?.info?.("clawgram skipping empty inbound text", {
275
421
  accountId,
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.3",
5
+ "version": "2.5.1",
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.3",
3
+ "version": "2.5.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": {