clawgram 2.4.3 → 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 +126 -2
- package/dist/index.js +3 -1
- package/dist/media.js +69 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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,
|
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.
|
|
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
|
+
}
|
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.5.0",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
package/package.json
CHANGED