openclaw-threema 0.7.0 → 0.7.4
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/CHANGELOG.md +59 -0
- package/dist/index.js +334 -12
- package/index.ts +350 -12
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.4 (2026-07-05)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Outbound image/file media in normal agent replies now works** (the standard
|
|
8
|
+
`MEDIA:` directive). Previously the reply `deliver` handlers only had branches
|
|
9
|
+
for audio-as-voice and text — any image/file attachment fell through into the
|
|
10
|
+
text path and was silently dropped (no blob upload, no error). The agent could
|
|
11
|
+
send a picture and the user would receive only the caption text, if anything.
|
|
12
|
+
- Both `deliver` handlers (E2E + fallback pipeline) now resolve outbound media the
|
|
13
|
+
way core supplies it: prefer `payload.mediaUrls` (array) and fall back to the
|
|
14
|
+
legacy `payload.mediaUrl` (string), matching core's `resolveOutboundMediaUrls`.
|
|
15
|
+
Non-audio media is sent via the existing `outbound.sendMedia` pipeline (which
|
|
16
|
+
already handles local-path/`MEDIA_ALLOWED_BASE` validation + blob upload), with
|
|
17
|
+
the reply text ridden along as the caption. If every media send fails, it falls
|
|
18
|
+
back to sending the text so the reply is never lost.
|
|
19
|
+
- Note: outbound media must still live under `~/.openclaw/media/` (exfiltration
|
|
20
|
+
guard, `MEDIA_ALLOWED_BASE`). Paths outside are rejected by design.
|
|
21
|
+
|
|
22
|
+
## 0.7.3 (2026-06-26)
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **Memory-briefing now uses "Variante C" (decided by aza 2026-06-21).** Previously
|
|
27
|
+
the full memory snapshot (Current State + entire Akut section) was appended to
|
|
28
|
+
*every* inbound Threema message — which read like an unsolicited status card and
|
|
29
|
+
burned tokens. New behavior:
|
|
30
|
+
- **Short briefing on every inbound:** just 1-2 core lines — live `openclaw --version`
|
|
31
|
+
plus the single most-acute pending one-liner.
|
|
32
|
+
- **Full snapshot only after an idle gap (> 2 h)** per session, so the agent
|
|
33
|
+
re-orients after longer pauses without spamming the full block each turn.
|
|
34
|
+
- **OpenClaw version is pulled LIVE** via `openclaw --version` (10-min cache),
|
|
35
|
+
instead of a static line copied from MEMORY.md — fixes the "briefing shows a
|
|
36
|
+
stale/wrong version" annoyance.
|
|
37
|
+
- Per-session idle tracking (`lastInboundBySession`), short/full briefings cached
|
|
38
|
+
60 s per workspace, version cached 10 min. Fail-silent throughout; CommandBody /
|
|
39
|
+
RawBody stay clean for slash-command parsing.
|
|
40
|
+
|
|
41
|
+
## 0.7.2 (2026-06-17)
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- Added `actions` adapter (`describeMessageTool` / `supportsAction` / `handleAction`)
|
|
46
|
+
so the shared `message` tool's `action=send` works on Threema. Previously core
|
|
47
|
+
threw `Channel threema does not support action send`, which also broke internal
|
|
48
|
+
flows such as the post-restart "interrupted main session recovery" notice.
|
|
49
|
+
Text + media sends reuse the existing `ThreemaClient` / `outbound.sendMedia`
|
|
50
|
+
pipeline (incl. SSRF/local-path validation). Accepts `to`/`target`/`chatId`
|
|
51
|
+
and `message`/`text`/`caption` plus `media`/`mediaUrl`/`filePath`/`path` aliases.
|
|
52
|
+
- Corrected stale `export const version` (was `0.6.7`) to track package version.
|
|
53
|
+
|
|
54
|
+
## 0.7.1 (2026-05-09)
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
- **`tweetnacl-util` named-import broke under OpenClaw 2026.5.7's stricter ESM loader.** OC 2026.5.7 no longer accepts named-imports against CommonJS modules that use `module.exports = X`. The plugin failed to load with `SyntaxError: The requested module 'tweetnacl-util' does not provide an export named 'decodeUTF8'`.
|
|
58
|
+
- Fix: swapped `import { decodeUTF8 } from "tweetnacl-util"` for `import naclUtil from "tweetnacl-util"; const { decodeUTF8 } = naclUtil;` — same shape as how we already import `tweetnacl` itself.
|
|
59
|
+
- Mirror of the in-place hotfix that was applied to `dist/index.js` on 2026-05-08; this release pulls it back into source so it survives a fresh `npm install`.
|
|
60
|
+
- No behavior change. Drop-in upgrade from 0.7.0.
|
|
61
|
+
|
|
3
62
|
## 0.7.0 (2026-05-06)
|
|
4
63
|
|
|
5
64
|
### Added
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* Includes media (file message) support with audio transcription.
|
|
7
7
|
*/
|
|
8
8
|
import nacl from "tweetnacl";
|
|
9
|
-
import
|
|
9
|
+
import naclUtil from "tweetnacl-util";
|
|
10
|
+
const { decodeUTF8 } = naclUtil;
|
|
10
11
|
import * as fs from "fs";
|
|
11
12
|
import * as path from "path";
|
|
12
13
|
import { spawnSync } from "child_process";
|
|
@@ -21,6 +22,74 @@ const MEDIA_INBOUND_DIR = path.join(process.env.HOME || "/tmp", ".openclaw", "me
|
|
|
21
22
|
const briefingCache = new Map();
|
|
22
23
|
const BRIEFING_CACHE_TTL_MS = 60_000;
|
|
23
24
|
const BRIEFING_MAX_BYTES = 8192;
|
|
25
|
+
// --- Variante C state (decided by aza 2026-06-21) ---
|
|
26
|
+
// Short briefing on every inbound (1-2 core lines: live OC version + most acute
|
|
27
|
+
// pending line). Full block ONLY once after a session gap > threshold.
|
|
28
|
+
const BRIEFING_FULL_GAP_MS = 2 * 60 * 60 * 1000; // 2 h
|
|
29
|
+
const lastInboundBySession = new Map();
|
|
30
|
+
// Live OpenClaw version, cached for a while (cheap spawn, but avoid per-msg cost).
|
|
31
|
+
let ocVersionCache = { value: "", expiresAt: 0 };
|
|
32
|
+
const OC_VERSION_TTL_MS = 10 * 60 * 1000; // 10 min
|
|
33
|
+
function getLiveOpenClawVersion() {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
if (ocVersionCache.value && ocVersionCache.expiresAt > now)
|
|
36
|
+
return ocVersionCache.value;
|
|
37
|
+
let version = "";
|
|
38
|
+
try {
|
|
39
|
+
const res = spawnSync("openclaw", ["--version"], { timeout: 4000, encoding: "utf8" });
|
|
40
|
+
if (res.status === 0 && typeof res.stdout === "string") {
|
|
41
|
+
// Output may be like "openclaw 2026.6.9" or just "2026.6.9"
|
|
42
|
+
const m = res.stdout.match(/\d{4}\.\d+\.\d+/);
|
|
43
|
+
version = m ? m[0] : res.stdout.trim().split(/\s+/).pop() || "";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* fail-silent */
|
|
48
|
+
}
|
|
49
|
+
ocVersionCache = { value: version, expiresAt: now + OC_VERSION_TTL_MS };
|
|
50
|
+
return version;
|
|
51
|
+
}
|
|
52
|
+
// Extract the single most-acute pending item as a short one-liner.
|
|
53
|
+
// The "Akut" section is organized as "### <Topic>" subsections; the first
|
|
54
|
+
// non-done (### that is not ~~struck~~ / not marked ERLEDIGT) topic title is
|
|
55
|
+
// the most useful single line. Falls back to the first content bullet.
|
|
56
|
+
function extractTopPendingLine(acutePending) {
|
|
57
|
+
if (!acutePending)
|
|
58
|
+
return "";
|
|
59
|
+
const lines = acutePending.split(/\r?\n/);
|
|
60
|
+
let firstContent = "";
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
const trimmed = lines[i].trim();
|
|
63
|
+
if (!trimmed)
|
|
64
|
+
continue;
|
|
65
|
+
if (/^##\s/.test(trimmed))
|
|
66
|
+
continue; // the "## Akut" header itself
|
|
67
|
+
if (/^_/.test(trimmed))
|
|
68
|
+
continue; // italic meta lines
|
|
69
|
+
// Prefer a "### Topic" subsection title that is not done/struck-through.
|
|
70
|
+
if (/^#{3,6}\s/.test(trimmed)) {
|
|
71
|
+
const title = trimmed.replace(/^#{3,6}\s+/, "").trim();
|
|
72
|
+
const isDone = /~~|ERLEDIGT|ABGESCHLOSSEN|\u2705/i.test(title);
|
|
73
|
+
if (!isDone) {
|
|
74
|
+
let clean = title.replace(/~~/g, "").replace(/\*\*/g, "").trim();
|
|
75
|
+
if (clean.length > 160)
|
|
76
|
+
clean = clean.slice(0, 157) + "...";
|
|
77
|
+
if (clean)
|
|
78
|
+
return clean;
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (/^[-*]\s*$/.test(trimmed))
|
|
83
|
+
continue; // empty bullets
|
|
84
|
+
if (!firstContent) {
|
|
85
|
+
let clean = trimmed.replace(/^[-*]\s+/, "").replace(/~~/g, "").replace(/\*\*/g, "").trim();
|
|
86
|
+
if (clean.length > 160)
|
|
87
|
+
clean = clean.slice(0, 157) + "...";
|
|
88
|
+
firstContent = clean;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return firstContent;
|
|
92
|
+
}
|
|
24
93
|
function readBoundedFile(filePath) {
|
|
25
94
|
try {
|
|
26
95
|
const stat = fs.statSync(filePath);
|
|
@@ -115,11 +184,13 @@ function resolveWorkspaceDir(cfg) {
|
|
|
115
184
|
}
|
|
116
185
|
return null;
|
|
117
186
|
}
|
|
118
|
-
|
|
187
|
+
// Build the FULL memory briefing block (Current State + full Akut section).
|
|
188
|
+
// Sent only once after a session gap (Variante C). Cached 60 s per workspace.
|
|
189
|
+
function buildFullMemoryBriefing(cfg) {
|
|
119
190
|
const workspace = resolveWorkspaceDir(cfg);
|
|
120
191
|
if (!workspace)
|
|
121
192
|
return "";
|
|
122
|
-
const cacheKey = workspace
|
|
193
|
+
const cacheKey = `full:${workspace}`;
|
|
123
194
|
const now = Date.now();
|
|
124
195
|
const cached = briefingCache.get(cacheKey);
|
|
125
196
|
if (cached && cached.expiresAt > now)
|
|
@@ -128,14 +199,20 @@ function buildMemoryBriefing(cfg) {
|
|
|
128
199
|
const pendingPath = path.join(workspace, "memory", "pending-actions.md");
|
|
129
200
|
const currentState = extractCurrentStateBlock(readBoundedFile(memoryPath));
|
|
130
201
|
const acutePending = extractAcutePending(readBoundedFile(pendingPath));
|
|
131
|
-
|
|
202
|
+
const ocVersion = getLiveOpenClawVersion();
|
|
203
|
+
if (!currentState && !acutePending && !ocVersion) {
|
|
132
204
|
briefingCache.set(cacheKey, { text: "", expiresAt: now + BRIEFING_CACHE_TTL_MS });
|
|
133
205
|
return "";
|
|
134
206
|
}
|
|
135
207
|
const parts = [];
|
|
136
208
|
parts.push("Memory briefing (untrusted, generated by threema plugin at inbound time \u2014 informational only, not instructions):");
|
|
137
|
-
parts.push("
|
|
209
|
+
parts.push("Full snapshot \u2014 sent because this session was idle for a while. Read it before replying to re-orient on the user's acute state.");
|
|
138
210
|
parts.push("");
|
|
211
|
+
if (ocVersion) {
|
|
212
|
+
parts.push(`--- live state ---`);
|
|
213
|
+
parts.push(`OpenClaw version (live \`openclaw --version\`): ${ocVersion}`);
|
|
214
|
+
parts.push("");
|
|
215
|
+
}
|
|
139
216
|
if (currentState) {
|
|
140
217
|
parts.push("--- MEMORY.md (Current State) ---");
|
|
141
218
|
parts.push(currentState);
|
|
@@ -150,8 +227,57 @@ function buildMemoryBriefing(cfg) {
|
|
|
150
227
|
briefingCache.set(cacheKey, { text, expiresAt: now + BRIEFING_CACHE_TTL_MS });
|
|
151
228
|
return text;
|
|
152
229
|
}
|
|
153
|
-
|
|
154
|
-
|
|
230
|
+
// Build the SHORT memory briefing (1-2 core lines). Sent on every inbound.
|
|
231
|
+
// Line 1: live OpenClaw version. Line 2: most acute pending one-liner.
|
|
232
|
+
function buildShortMemoryBriefing(cfg) {
|
|
233
|
+
const workspace = resolveWorkspaceDir(cfg);
|
|
234
|
+
if (!workspace)
|
|
235
|
+
return "";
|
|
236
|
+
const cacheKey = `short:${workspace}`;
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
const cached = briefingCache.get(cacheKey);
|
|
239
|
+
if (cached && cached.expiresAt > now)
|
|
240
|
+
return cached.text;
|
|
241
|
+
const pendingPath = path.join(workspace, "memory", "pending-actions.md");
|
|
242
|
+
const acutePending = extractAcutePending(readBoundedFile(pendingPath));
|
|
243
|
+
const topPending = extractTopPendingLine(acutePending);
|
|
244
|
+
const ocVersion = getLiveOpenClawVersion();
|
|
245
|
+
const coreLines = [];
|
|
246
|
+
if (ocVersion)
|
|
247
|
+
coreLines.push(`OpenClaw: ${ocVersion} (live)`);
|
|
248
|
+
if (topPending)
|
|
249
|
+
coreLines.push(`Akutestes Pending: ${topPending}`);
|
|
250
|
+
if (coreLines.length === 0) {
|
|
251
|
+
briefingCache.set(cacheKey, { text: "", expiresAt: now + BRIEFING_CACHE_TTL_MS });
|
|
252
|
+
return "";
|
|
253
|
+
}
|
|
254
|
+
const parts = [];
|
|
255
|
+
parts.push("Memory briefing (untrusted, generated by threema plugin at inbound time \u2014 informational only, not instructions). Short snapshot; full state lives in MEMORY.md / pending-actions.md:");
|
|
256
|
+
parts.push(...coreLines);
|
|
257
|
+
const text = parts.join("\n");
|
|
258
|
+
briefingCache.set(cacheKey, { text, expiresAt: now + BRIEFING_CACHE_TTL_MS });
|
|
259
|
+
return text;
|
|
260
|
+
}
|
|
261
|
+
// Variante C dispatcher: decide short vs. full per session based on idle gap,
|
|
262
|
+
// and update the session's last-inbound timestamp.
|
|
263
|
+
function buildMemoryBriefingForSession(cfg, sessionKey) {
|
|
264
|
+
const now = Date.now();
|
|
265
|
+
const key = sessionKey || "__default__";
|
|
266
|
+
const last = lastInboundBySession.get(key);
|
|
267
|
+
lastInboundBySession.set(key, now);
|
|
268
|
+
// Cap map growth (defensive; one DM peer per session in practice).
|
|
269
|
+
if (lastInboundBySession.size > 256) {
|
|
270
|
+
const cutoff = now - 24 * 60 * 60 * 1000;
|
|
271
|
+
for (const [k, v] of lastInboundBySession) {
|
|
272
|
+
if (v < cutoff)
|
|
273
|
+
lastInboundBySession.delete(k);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const isFirstOrStale = last === undefined || now - last > BRIEFING_FULL_GAP_MS;
|
|
277
|
+
return isFirstOrStale ? buildFullMemoryBriefing(cfg) : buildShortMemoryBriefing(cfg);
|
|
278
|
+
}
|
|
279
|
+
function composeBodyForAgent(userText, cfg, sessionKey) {
|
|
280
|
+
const briefing = buildMemoryBriefingForSession(cfg, sessionKey);
|
|
155
281
|
if (!briefing)
|
|
156
282
|
return userText;
|
|
157
283
|
return `${userText}\n\n[memory_briefing]\n${briefing}\n[/memory_briefing]`;
|
|
@@ -1512,6 +1638,124 @@ const threemaChannel = {
|
|
|
1512
1638
|
});
|
|
1513
1639
|
},
|
|
1514
1640
|
},
|
|
1641
|
+
// ============================================================================
|
|
1642
|
+
// Message-Tool Action Adapter — powers the shared `message` tool
|
|
1643
|
+
// (action=send) and internal flows like restart-recovery notices.
|
|
1644
|
+
// Without this, core throws "Channel threema does not support action send".
|
|
1645
|
+
// Normal agent replies go through `outbound.sendText`; this is the
|
|
1646
|
+
// explicit/proactive send surface.
|
|
1647
|
+
// ============================================================================
|
|
1648
|
+
actions: {
|
|
1649
|
+
describeMessageTool: (_params) => {
|
|
1650
|
+
return {
|
|
1651
|
+
actions: ["send"],
|
|
1652
|
+
capabilities: [],
|
|
1653
|
+
};
|
|
1654
|
+
},
|
|
1655
|
+
supportsAction: ({ action }) => action === "send",
|
|
1656
|
+
handleAction: async (ctx) => {
|
|
1657
|
+
const { action, params, cfg } = ctx;
|
|
1658
|
+
if (action !== "send") {
|
|
1659
|
+
return {
|
|
1660
|
+
isError: true,
|
|
1661
|
+
content: [
|
|
1662
|
+
{ type: "text", text: `Threema: unsupported action "${action}".` },
|
|
1663
|
+
],
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
const threemaCfg = getThreemaConfig(cfg);
|
|
1667
|
+
if (!threemaCfg?.gatewayId || !threemaCfg?.secretKey) {
|
|
1668
|
+
return {
|
|
1669
|
+
isError: true,
|
|
1670
|
+
content: [
|
|
1671
|
+
{ type: "text", text: "Threema not configured: missing gatewayId or secretKey." },
|
|
1672
|
+
],
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
// Resolve destination from common param aliases.
|
|
1676
|
+
const rawTo = (typeof params?.to === "string" && params.to) ||
|
|
1677
|
+
(typeof params?.target === "string" && params.target) ||
|
|
1678
|
+
(typeof params?.chatId === "string" && params.chatId) ||
|
|
1679
|
+
"";
|
|
1680
|
+
if (!rawTo) {
|
|
1681
|
+
return {
|
|
1682
|
+
isError: true,
|
|
1683
|
+
content: [{ type: "text", text: "Threema send: missing 'to'/'target'." }],
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
const to = normalizeThreemaTarget(rawTo);
|
|
1687
|
+
if (!isValidThreemaId(to)) {
|
|
1688
|
+
return {
|
|
1689
|
+
isError: true,
|
|
1690
|
+
content: [{ type: "text", text: `Threema send: invalid Threema ID "${rawTo}".` }],
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
const mediaUrl = (typeof params?.media === "string" && params.media) ||
|
|
1694
|
+
(typeof params?.mediaUrl === "string" && params.mediaUrl) ||
|
|
1695
|
+
(typeof params?.filePath === "string" && params.filePath) ||
|
|
1696
|
+
(typeof params?.path === "string" && params.path) ||
|
|
1697
|
+
"";
|
|
1698
|
+
const text = (typeof params?.message === "string" && params.message) ||
|
|
1699
|
+
(typeof params?.text === "string" && params.text) ||
|
|
1700
|
+
(typeof params?.caption === "string" && params.caption) ||
|
|
1701
|
+
"";
|
|
1702
|
+
if (!text && !mediaUrl) {
|
|
1703
|
+
return {
|
|
1704
|
+
isError: true,
|
|
1705
|
+
content: [{ type: "text", text: "Threema send: nothing to send (no message or media)." }],
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
try {
|
|
1709
|
+
const client = new ThreemaClient(threemaCfg);
|
|
1710
|
+
// Media path: delegate to the same outbound.sendMedia pipeline so all
|
|
1711
|
+
// SSRF/local-path validation stays in one place.
|
|
1712
|
+
if (mediaUrl) {
|
|
1713
|
+
const result = await threemaChannel.outbound.sendMedia({
|
|
1714
|
+
cfg,
|
|
1715
|
+
to,
|
|
1716
|
+
text,
|
|
1717
|
+
mediaUrl,
|
|
1718
|
+
});
|
|
1719
|
+
return {
|
|
1720
|
+
content: [
|
|
1721
|
+
{
|
|
1722
|
+
type: "text",
|
|
1723
|
+
text: `Threema media sent to ${to} (messageId ${result?.messageId ?? "?"}).`,
|
|
1724
|
+
},
|
|
1725
|
+
],
|
|
1726
|
+
meta: result,
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
// Text path.
|
|
1730
|
+
const formatted = markdownToThreema(text);
|
|
1731
|
+
const messageId = client.isE2EEnabled
|
|
1732
|
+
? await client.sendE2E(to, formatted)
|
|
1733
|
+
: await client.sendSimple(to, formatted);
|
|
1734
|
+
return {
|
|
1735
|
+
content: [
|
|
1736
|
+
{
|
|
1737
|
+
type: "text",
|
|
1738
|
+
text: `Threema message sent to ${to} (messageId ${messageId.trim()}).`,
|
|
1739
|
+
},
|
|
1740
|
+
],
|
|
1741
|
+
meta: {
|
|
1742
|
+
channel: "threema",
|
|
1743
|
+
messageId: messageId.trim(),
|
|
1744
|
+
chatId: to,
|
|
1745
|
+
timestamp: Date.now(),
|
|
1746
|
+
},
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
catch (err) {
|
|
1750
|
+
return {
|
|
1751
|
+
isError: true,
|
|
1752
|
+
content: [
|
|
1753
|
+
{ type: "text", text: `Threema send failed: ${err?.message ?? String(err)}` },
|
|
1754
|
+
],
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
},
|
|
1758
|
+
},
|
|
1515
1759
|
};
|
|
1516
1760
|
const COALESCE_WINDOW_MS = 3000; // 3 second coalescing window
|
|
1517
1761
|
const COALESCE_MAX_PARTS = 5; // Max parts before flushing
|
|
@@ -1644,7 +1888,7 @@ async function queueInboundForCoalescing(sender, part, dispatcher) {
|
|
|
1644
1888
|
}
|
|
1645
1889
|
export const id = "threema";
|
|
1646
1890
|
export const name = "Threema Gateway";
|
|
1647
|
-
export const version = "0.
|
|
1891
|
+
export const version = "0.7.4";
|
|
1648
1892
|
export const description = "Threema messaging channel via Threema Gateway API (E2E encrypted, with media support)";
|
|
1649
1893
|
export default function register(api) {
|
|
1650
1894
|
try {
|
|
@@ -1790,7 +2034,11 @@ export default function register(api) {
|
|
|
1790
2034
|
const channelRuntime = runtime?.channel;
|
|
1791
2035
|
if (channelRuntime?.routing?.resolveAgentRoute && channelRuntime?.reply?.finalizeInboundContext && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
1792
2036
|
try {
|
|
1793
|
-
|
|
2037
|
+
// Use the read-only runtime config snapshot. loadConfig() is
|
|
2038
|
+
// deprecated (deprecated-internal-config-api guard) and slated
|
|
2039
|
+
// for removal; current() returns a DeepReadonly<OpenClawConfig>
|
|
2040
|
+
// which we only read here (route resolution + memory briefing).
|
|
2041
|
+
const currentCfg = runtime.config.current();
|
|
1794
2042
|
// 1. Resolve the agent route and session key
|
|
1795
2043
|
const route = channelRuntime.routing.resolveAgentRoute({
|
|
1796
2044
|
cfg: currentCfg,
|
|
@@ -1812,7 +2060,7 @@ export default function register(api) {
|
|
|
1812
2060
|
// sees the current acute state on every inbound, regardless
|
|
1813
2061
|
// of how long the session has been running. CommandBody and
|
|
1814
2062
|
// RawBody stay clean for slash-command parsing.
|
|
1815
|
-
const bodyForAgent = composeBodyForAgent(merged.mergedText, currentCfg);
|
|
2063
|
+
const bodyForAgent = composeBodyForAgent(merged.mergedText, currentCfg, sessionKey);
|
|
1816
2064
|
const msgCtx = channelRuntime.reply.finalizeInboundContext({
|
|
1817
2065
|
Body: merged.mergedText,
|
|
1818
2066
|
RawBody: merged.mergedText,
|
|
@@ -1887,6 +2135,42 @@ export default function register(api) {
|
|
|
1887
2135
|
}
|
|
1888
2136
|
}
|
|
1889
2137
|
else {
|
|
2138
|
+
// Media path (non-audio): if the reply carries image/file media,
|
|
2139
|
+
// send it as a Threema file message. Core provides media as
|
|
2140
|
+
// `mediaUrls` (array, preferred) or legacy `mediaUrl` (string).
|
|
2141
|
+
const mediaUrls = Array.isArray(payload.mediaUrls) && payload.mediaUrls.length > 0
|
|
2142
|
+
? payload.mediaUrls.filter((u) => typeof u === "string" && u.length > 0)
|
|
2143
|
+
: (typeof payload.mediaUrl === "string" && payload.mediaUrl
|
|
2144
|
+
? [payload.mediaUrl]
|
|
2145
|
+
: []);
|
|
2146
|
+
if (mediaUrls.length > 0 && replyClient.isE2EEnabled) {
|
|
2147
|
+
const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
|
|
2148
|
+
let sentAny = false;
|
|
2149
|
+
for (let i = 0; i < mediaUrls.length; i++) {
|
|
2150
|
+
const mu = mediaUrls[i];
|
|
2151
|
+
try {
|
|
2152
|
+
await threemaChannel.outbound.sendMedia({
|
|
2153
|
+
cfg: currentCfg,
|
|
2154
|
+
to: from,
|
|
2155
|
+
text: i === 0 ? (caption ?? "") : "",
|
|
2156
|
+
mediaUrl: mu,
|
|
2157
|
+
});
|
|
2158
|
+
sentAny = true;
|
|
2159
|
+
}
|
|
2160
|
+
catch (mediaErr) {
|
|
2161
|
+
api.logger?.error?.(`Threema: media send failed for ${mu}: ${mediaErr?.message}`);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
// If at least one media went out, we're done (caption rode along).
|
|
2165
|
+
// If ALL media failed, fall through to text so the reply isn't lost.
|
|
2166
|
+
if (sentAny)
|
|
2167
|
+
return;
|
|
2168
|
+
const fallbackText = markdownToThreema(payload.text ?? payload.body);
|
|
2169
|
+
if (fallbackText) {
|
|
2170
|
+
await replyClient.sendE2E(from, fallbackText);
|
|
2171
|
+
}
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
1890
2174
|
// Send as text (existing logic)
|
|
1891
2175
|
const text = markdownToThreema(payload.text ?? payload.body);
|
|
1892
2176
|
if (!text)
|
|
@@ -2016,7 +2300,9 @@ export default function register(api) {
|
|
|
2016
2300
|
&& channelRuntime?.reply?.finalizeInboundContext
|
|
2017
2301
|
&& channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
2018
2302
|
try {
|
|
2019
|
-
|
|
2303
|
+
// Read-only runtime config snapshot (see text path above):
|
|
2304
|
+
// loadConfig() is deprecated and on the removal list.
|
|
2305
|
+
const currentCfg = runtime.config.current();
|
|
2020
2306
|
// Resolve the same agent route + session key the text path uses
|
|
2021
2307
|
// so file inbounds end up in the live Threema DM session.
|
|
2022
2308
|
const route = channelRuntime.routing.resolveAgentRoute({
|
|
@@ -2033,7 +2319,7 @@ export default function register(api) {
|
|
|
2033
2319
|
dmScope: "per-account-channel-peer",
|
|
2034
2320
|
});
|
|
2035
2321
|
const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
|
|
2036
|
-
const bodyForAgent = composeBodyForAgent(fileBody, currentCfg);
|
|
2322
|
+
const bodyForAgent = composeBodyForAgent(fileBody, currentCfg, sessionKey);
|
|
2037
2323
|
const fileCtx = channelRuntime.reply.finalizeInboundContext({
|
|
2038
2324
|
Body: fileBody,
|
|
2039
2325
|
RawBody: fileBody,
|
|
@@ -2111,6 +2397,42 @@ export default function register(api) {
|
|
|
2111
2397
|
}
|
|
2112
2398
|
}
|
|
2113
2399
|
else {
|
|
2400
|
+
// Media path (non-audio): if the reply carries image/file media,
|
|
2401
|
+
// send it as a Threema file message. Core provides media as
|
|
2402
|
+
// `mediaUrls` (array, preferred) or legacy `mediaUrl` (string).
|
|
2403
|
+
const mediaUrls = Array.isArray(payload.mediaUrls) && payload.mediaUrls.length > 0
|
|
2404
|
+
? payload.mediaUrls.filter((u) => typeof u === "string" && u.length > 0)
|
|
2405
|
+
: (typeof payload.mediaUrl === "string" && payload.mediaUrl
|
|
2406
|
+
? [payload.mediaUrl]
|
|
2407
|
+
: []);
|
|
2408
|
+
if (mediaUrls.length > 0 && replyClient.isE2EEnabled) {
|
|
2409
|
+
const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
|
|
2410
|
+
let sentAny = false;
|
|
2411
|
+
for (let i = 0; i < mediaUrls.length; i++) {
|
|
2412
|
+
const mu = mediaUrls[i];
|
|
2413
|
+
try {
|
|
2414
|
+
await threemaChannel.outbound.sendMedia({
|
|
2415
|
+
cfg: currentCfg,
|
|
2416
|
+
to: from,
|
|
2417
|
+
text: i === 0 ? (caption ?? "") : "",
|
|
2418
|
+
mediaUrl: mu,
|
|
2419
|
+
});
|
|
2420
|
+
sentAny = true;
|
|
2421
|
+
}
|
|
2422
|
+
catch (mediaErr) {
|
|
2423
|
+
api.logger?.error?.(`Threema: media send failed for ${mu}: ${mediaErr?.message}`);
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
// If at least one media went out, we're done (caption rode along).
|
|
2427
|
+
// If ALL media failed, fall through to text so the reply isn't lost.
|
|
2428
|
+
if (sentAny)
|
|
2429
|
+
return;
|
|
2430
|
+
const fallbackText = markdownToThreema(payload.text ?? payload.body);
|
|
2431
|
+
if (fallbackText) {
|
|
2432
|
+
await replyClient.sendE2E(from, fallbackText);
|
|
2433
|
+
}
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2114
2436
|
// Send as text (existing logic)
|
|
2115
2437
|
const text = markdownToThreema(payload.text ?? payload.body);
|
|
2116
2438
|
if (!text)
|
package/index.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import nacl from "tweetnacl";
|
|
10
|
-
import
|
|
10
|
+
import naclUtil from "tweetnacl-util";
|
|
11
|
+
const { decodeUTF8 } = naclUtil;
|
|
11
12
|
import * as fs from "fs";
|
|
12
13
|
import * as path from "path";
|
|
13
14
|
import { spawnSync } from "child_process";
|
|
@@ -173,6 +174,67 @@ const briefingCache: Map<string, BriefingCacheEntry> = new Map();
|
|
|
173
174
|
const BRIEFING_CACHE_TTL_MS = 60_000;
|
|
174
175
|
const BRIEFING_MAX_BYTES = 8192;
|
|
175
176
|
|
|
177
|
+
// --- Variante C state (decided by aza 2026-06-21) ---
|
|
178
|
+
// Short briefing on every inbound (1-2 core lines: live OC version + most acute
|
|
179
|
+
// pending line). Full block ONLY once after a session gap > threshold.
|
|
180
|
+
const BRIEFING_FULL_GAP_MS = 2 * 60 * 60 * 1000; // 2 h
|
|
181
|
+
const lastInboundBySession: Map<string, number> = new Map();
|
|
182
|
+
// Live OpenClaw version, cached for a while (cheap spawn, but avoid per-msg cost).
|
|
183
|
+
let ocVersionCache: { value: string; expiresAt: number } = { value: "", expiresAt: 0 };
|
|
184
|
+
const OC_VERSION_TTL_MS = 10 * 60 * 1000; // 10 min
|
|
185
|
+
|
|
186
|
+
function getLiveOpenClawVersion(): string {
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
if (ocVersionCache.value && ocVersionCache.expiresAt > now) return ocVersionCache.value;
|
|
189
|
+
let version = "";
|
|
190
|
+
try {
|
|
191
|
+
const res = spawnSync("openclaw", ["--version"], { timeout: 4000, encoding: "utf8" });
|
|
192
|
+
if (res.status === 0 && typeof res.stdout === "string") {
|
|
193
|
+
// Output may be like "openclaw 2026.6.9" or just "2026.6.9"
|
|
194
|
+
const m = res.stdout.match(/\d{4}\.\d+\.\d+/);
|
|
195
|
+
version = m ? m[0] : res.stdout.trim().split(/\s+/).pop() || "";
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
/* fail-silent */
|
|
199
|
+
}
|
|
200
|
+
ocVersionCache = { value: version, expiresAt: now + OC_VERSION_TTL_MS };
|
|
201
|
+
return version;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Extract the single most-acute pending item as a short one-liner.
|
|
205
|
+
// The "Akut" section is organized as "### <Topic>" subsections; the first
|
|
206
|
+
// non-done (### that is not ~~struck~~ / not marked ERLEDIGT) topic title is
|
|
207
|
+
// the most useful single line. Falls back to the first content bullet.
|
|
208
|
+
function extractTopPendingLine(acutePending: string): string {
|
|
209
|
+
if (!acutePending) return "";
|
|
210
|
+
const lines = acutePending.split(/\r?\n/);
|
|
211
|
+
let firstContent = "";
|
|
212
|
+
for (let i = 0; i < lines.length; i++) {
|
|
213
|
+
const trimmed = lines[i].trim();
|
|
214
|
+
if (!trimmed) continue;
|
|
215
|
+
if (/^##\s/.test(trimmed)) continue; // the "## Akut" header itself
|
|
216
|
+
if (/^_/.test(trimmed)) continue; // italic meta lines
|
|
217
|
+
// Prefer a "### Topic" subsection title that is not done/struck-through.
|
|
218
|
+
if (/^#{3,6}\s/.test(trimmed)) {
|
|
219
|
+
const title = trimmed.replace(/^#{3,6}\s+/, "").trim();
|
|
220
|
+
const isDone = /~~|ERLEDIGT|ABGESCHLOSSEN|\u2705/i.test(title);
|
|
221
|
+
if (!isDone) {
|
|
222
|
+
let clean = title.replace(/~~/g, "").replace(/\*\*/g, "").trim();
|
|
223
|
+
if (clean.length > 160) clean = clean.slice(0, 157) + "...";
|
|
224
|
+
if (clean) return clean;
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (/^[-*]\s*$/.test(trimmed)) continue; // empty bullets
|
|
229
|
+
if (!firstContent) {
|
|
230
|
+
let clean = trimmed.replace(/^[-*]\s+/, "").replace(/~~/g, "").replace(/\*\*/g, "").trim();
|
|
231
|
+
if (clean.length > 160) clean = clean.slice(0, 157) + "...";
|
|
232
|
+
firstContent = clean;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return firstContent;
|
|
236
|
+
}
|
|
237
|
+
|
|
176
238
|
function readBoundedFile(filePath: string): string {
|
|
177
239
|
try {
|
|
178
240
|
const stat = fs.statSync(filePath);
|
|
@@ -258,10 +320,12 @@ function resolveWorkspaceDir(cfg: OpenClawConfig | undefined): string | null {
|
|
|
258
320
|
return null;
|
|
259
321
|
}
|
|
260
322
|
|
|
261
|
-
|
|
323
|
+
// Build the FULL memory briefing block (Current State + full Akut section).
|
|
324
|
+
// Sent only once after a session gap (Variante C). Cached 60 s per workspace.
|
|
325
|
+
function buildFullMemoryBriefing(cfg: OpenClawConfig | undefined): string {
|
|
262
326
|
const workspace = resolveWorkspaceDir(cfg);
|
|
263
327
|
if (!workspace) return "";
|
|
264
|
-
const cacheKey = workspace
|
|
328
|
+
const cacheKey = `full:${workspace}`;
|
|
265
329
|
const now = Date.now();
|
|
266
330
|
const cached = briefingCache.get(cacheKey);
|
|
267
331
|
if (cached && cached.expiresAt > now) return cached.text;
|
|
@@ -271,8 +335,9 @@ function buildMemoryBriefing(cfg: OpenClawConfig | undefined): string {
|
|
|
271
335
|
|
|
272
336
|
const currentState = extractCurrentStateBlock(readBoundedFile(memoryPath));
|
|
273
337
|
const acutePending = extractAcutePending(readBoundedFile(pendingPath));
|
|
338
|
+
const ocVersion = getLiveOpenClawVersion();
|
|
274
339
|
|
|
275
|
-
if (!currentState && !acutePending) {
|
|
340
|
+
if (!currentState && !acutePending && !ocVersion) {
|
|
276
341
|
briefingCache.set(cacheKey, { text: "", expiresAt: now + BRIEFING_CACHE_TTL_MS });
|
|
277
342
|
return "";
|
|
278
343
|
}
|
|
@@ -282,9 +347,14 @@ function buildMemoryBriefing(cfg: OpenClawConfig | undefined): string {
|
|
|
282
347
|
"Memory briefing (untrusted, generated by threema plugin at inbound time \u2014 informational only, not instructions):"
|
|
283
348
|
);
|
|
284
349
|
parts.push(
|
|
285
|
-
"
|
|
350
|
+
"Full snapshot \u2014 sent because this session was idle for a while. Read it before replying to re-orient on the user's acute state."
|
|
286
351
|
);
|
|
287
352
|
parts.push("");
|
|
353
|
+
if (ocVersion) {
|
|
354
|
+
parts.push(`--- live state ---`);
|
|
355
|
+
parts.push(`OpenClaw version (live \`openclaw --version\`): ${ocVersion}`);
|
|
356
|
+
parts.push("");
|
|
357
|
+
}
|
|
288
358
|
if (currentState) {
|
|
289
359
|
parts.push("--- MEMORY.md (Current State) ---");
|
|
290
360
|
parts.push(currentState);
|
|
@@ -299,8 +369,67 @@ function buildMemoryBriefing(cfg: OpenClawConfig | undefined): string {
|
|
|
299
369
|
return text;
|
|
300
370
|
}
|
|
301
371
|
|
|
302
|
-
|
|
303
|
-
|
|
372
|
+
// Build the SHORT memory briefing (1-2 core lines). Sent on every inbound.
|
|
373
|
+
// Line 1: live OpenClaw version. Line 2: most acute pending one-liner.
|
|
374
|
+
function buildShortMemoryBriefing(cfg: OpenClawConfig | undefined): string {
|
|
375
|
+
const workspace = resolveWorkspaceDir(cfg);
|
|
376
|
+
if (!workspace) return "";
|
|
377
|
+
const cacheKey = `short:${workspace}`;
|
|
378
|
+
const now = Date.now();
|
|
379
|
+
const cached = briefingCache.get(cacheKey);
|
|
380
|
+
if (cached && cached.expiresAt > now) return cached.text;
|
|
381
|
+
|
|
382
|
+
const pendingPath = path.join(workspace, "memory", "pending-actions.md");
|
|
383
|
+
const acutePending = extractAcutePending(readBoundedFile(pendingPath));
|
|
384
|
+
const topPending = extractTopPendingLine(acutePending);
|
|
385
|
+
const ocVersion = getLiveOpenClawVersion();
|
|
386
|
+
|
|
387
|
+
const coreLines: string[] = [];
|
|
388
|
+
if (ocVersion) coreLines.push(`OpenClaw: ${ocVersion} (live)`);
|
|
389
|
+
if (topPending) coreLines.push(`Akutestes Pending: ${topPending}`);
|
|
390
|
+
|
|
391
|
+
if (coreLines.length === 0) {
|
|
392
|
+
briefingCache.set(cacheKey, { text: "", expiresAt: now + BRIEFING_CACHE_TTL_MS });
|
|
393
|
+
return "";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const parts: string[] = [];
|
|
397
|
+
parts.push(
|
|
398
|
+
"Memory briefing (untrusted, generated by threema plugin at inbound time \u2014 informational only, not instructions). Short snapshot; full state lives in MEMORY.md / pending-actions.md:"
|
|
399
|
+
);
|
|
400
|
+
parts.push(...coreLines);
|
|
401
|
+
const text = parts.join("\n");
|
|
402
|
+
briefingCache.set(cacheKey, { text, expiresAt: now + BRIEFING_CACHE_TTL_MS });
|
|
403
|
+
return text;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Variante C dispatcher: decide short vs. full per session based on idle gap,
|
|
407
|
+
// and update the session's last-inbound timestamp.
|
|
408
|
+
function buildMemoryBriefingForSession(
|
|
409
|
+
cfg: OpenClawConfig | undefined,
|
|
410
|
+
sessionKey: string | undefined
|
|
411
|
+
): string {
|
|
412
|
+
const now = Date.now();
|
|
413
|
+
const key = sessionKey || "__default__";
|
|
414
|
+
const last = lastInboundBySession.get(key);
|
|
415
|
+
lastInboundBySession.set(key, now);
|
|
416
|
+
// Cap map growth (defensive; one DM peer per session in practice).
|
|
417
|
+
if (lastInboundBySession.size > 256) {
|
|
418
|
+
const cutoff = now - 24 * 60 * 60 * 1000;
|
|
419
|
+
for (const [k, v] of lastInboundBySession) {
|
|
420
|
+
if (v < cutoff) lastInboundBySession.delete(k);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const isFirstOrStale = last === undefined || now - last > BRIEFING_FULL_GAP_MS;
|
|
424
|
+
return isFirstOrStale ? buildFullMemoryBriefing(cfg) : buildShortMemoryBriefing(cfg);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function composeBodyForAgent(
|
|
428
|
+
userText: string,
|
|
429
|
+
cfg: OpenClawConfig | undefined,
|
|
430
|
+
sessionKey?: string
|
|
431
|
+
): string {
|
|
432
|
+
const briefing = buildMemoryBriefingForSession(cfg, sessionKey);
|
|
304
433
|
if (!briefing) return userText;
|
|
305
434
|
return `${userText}\n\n[memory_briefing]\n${briefing}\n[/memory_briefing]`;
|
|
306
435
|
}
|
|
@@ -1950,6 +2079,139 @@ const threemaChannel = {
|
|
|
1950
2079
|
});
|
|
1951
2080
|
},
|
|
1952
2081
|
},
|
|
2082
|
+
|
|
2083
|
+
// ============================================================================
|
|
2084
|
+
// Message-Tool Action Adapter — powers the shared `message` tool
|
|
2085
|
+
// (action=send) and internal flows like restart-recovery notices.
|
|
2086
|
+
// Without this, core throws "Channel threema does not support action send".
|
|
2087
|
+
// Normal agent replies go through `outbound.sendText`; this is the
|
|
2088
|
+
// explicit/proactive send surface.
|
|
2089
|
+
// ============================================================================
|
|
2090
|
+
actions: {
|
|
2091
|
+
describeMessageTool: (_params: any) => {
|
|
2092
|
+
return {
|
|
2093
|
+
actions: ["send"] as const,
|
|
2094
|
+
capabilities: [] as const,
|
|
2095
|
+
};
|
|
2096
|
+
},
|
|
2097
|
+
|
|
2098
|
+
supportsAction: ({ action }: { action: string }): boolean =>
|
|
2099
|
+
action === "send",
|
|
2100
|
+
|
|
2101
|
+
handleAction: async (ctx: any): Promise<any> => {
|
|
2102
|
+
const { action, params, cfg } = ctx;
|
|
2103
|
+
|
|
2104
|
+
if (action !== "send") {
|
|
2105
|
+
return {
|
|
2106
|
+
isError: true,
|
|
2107
|
+
content: [
|
|
2108
|
+
{ type: "text", text: `Threema: unsupported action "${action}".` },
|
|
2109
|
+
],
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
const threemaCfg = getThreemaConfig(cfg);
|
|
2114
|
+
if (!threemaCfg?.gatewayId || !threemaCfg?.secretKey) {
|
|
2115
|
+
return {
|
|
2116
|
+
isError: true,
|
|
2117
|
+
content: [
|
|
2118
|
+
{ type: "text", text: "Threema not configured: missing gatewayId or secretKey." },
|
|
2119
|
+
],
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// Resolve destination from common param aliases.
|
|
2124
|
+
const rawTo =
|
|
2125
|
+
(typeof params?.to === "string" && params.to) ||
|
|
2126
|
+
(typeof params?.target === "string" && params.target) ||
|
|
2127
|
+
(typeof params?.chatId === "string" && params.chatId) ||
|
|
2128
|
+
"";
|
|
2129
|
+
if (!rawTo) {
|
|
2130
|
+
return {
|
|
2131
|
+
isError: true,
|
|
2132
|
+
content: [{ type: "text", text: "Threema send: missing 'to'/'target'." }],
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
const to = normalizeThreemaTarget(rawTo);
|
|
2136
|
+
if (!isValidThreemaId(to)) {
|
|
2137
|
+
return {
|
|
2138
|
+
isError: true,
|
|
2139
|
+
content: [{ type: "text", text: `Threema send: invalid Threema ID "${rawTo}".` }],
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
const mediaUrl =
|
|
2144
|
+
(typeof params?.media === "string" && params.media) ||
|
|
2145
|
+
(typeof params?.mediaUrl === "string" && params.mediaUrl) ||
|
|
2146
|
+
(typeof params?.filePath === "string" && params.filePath) ||
|
|
2147
|
+
(typeof params?.path === "string" && params.path) ||
|
|
2148
|
+
"";
|
|
2149
|
+
const text =
|
|
2150
|
+
(typeof params?.message === "string" && params.message) ||
|
|
2151
|
+
(typeof params?.text === "string" && params.text) ||
|
|
2152
|
+
(typeof params?.caption === "string" && params.caption) ||
|
|
2153
|
+
"";
|
|
2154
|
+
|
|
2155
|
+
if (!text && !mediaUrl) {
|
|
2156
|
+
return {
|
|
2157
|
+
isError: true,
|
|
2158
|
+
content: [{ type: "text", text: "Threema send: nothing to send (no message or media)." }],
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
try {
|
|
2163
|
+
const client = new ThreemaClient(threemaCfg);
|
|
2164
|
+
|
|
2165
|
+
// Media path: delegate to the same outbound.sendMedia pipeline so all
|
|
2166
|
+
// SSRF/local-path validation stays in one place.
|
|
2167
|
+
if (mediaUrl) {
|
|
2168
|
+
const result = await threemaChannel.outbound.sendMedia({
|
|
2169
|
+
cfg,
|
|
2170
|
+
to,
|
|
2171
|
+
text,
|
|
2172
|
+
mediaUrl,
|
|
2173
|
+
} as any);
|
|
2174
|
+
return {
|
|
2175
|
+
content: [
|
|
2176
|
+
{
|
|
2177
|
+
type: "text",
|
|
2178
|
+
text: `Threema media sent to ${to} (messageId ${(result as any)?.messageId ?? "?"}).`,
|
|
2179
|
+
},
|
|
2180
|
+
],
|
|
2181
|
+
meta: result,
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// Text path.
|
|
2186
|
+
const formatted = markdownToThreema(text);
|
|
2187
|
+
const messageId = client.isE2EEnabled
|
|
2188
|
+
? await client.sendE2E(to, formatted)
|
|
2189
|
+
: await client.sendSimple(to, formatted);
|
|
2190
|
+
|
|
2191
|
+
return {
|
|
2192
|
+
content: [
|
|
2193
|
+
{
|
|
2194
|
+
type: "text",
|
|
2195
|
+
text: `Threema message sent to ${to} (messageId ${messageId.trim()}).`,
|
|
2196
|
+
},
|
|
2197
|
+
],
|
|
2198
|
+
meta: {
|
|
2199
|
+
channel: "threema",
|
|
2200
|
+
messageId: messageId.trim(),
|
|
2201
|
+
chatId: to,
|
|
2202
|
+
timestamp: Date.now(),
|
|
2203
|
+
},
|
|
2204
|
+
};
|
|
2205
|
+
} catch (err: any) {
|
|
2206
|
+
return {
|
|
2207
|
+
isError: true,
|
|
2208
|
+
content: [
|
|
2209
|
+
{ type: "text", text: `Threema send failed: ${err?.message ?? String(err)}` },
|
|
2210
|
+
],
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
},
|
|
2214
|
+
},
|
|
1953
2215
|
};
|
|
1954
2216
|
|
|
1955
2217
|
// ============================================================================
|
|
@@ -2121,7 +2383,7 @@ async function queueInboundForCoalescing(
|
|
|
2121
2383
|
|
|
2122
2384
|
export const id = "threema";
|
|
2123
2385
|
export const name = "Threema Gateway";
|
|
2124
|
-
export const version = "0.
|
|
2386
|
+
export const version = "0.7.4";
|
|
2125
2387
|
export const description =
|
|
2126
2388
|
"Threema messaging channel via Threema Gateway API (E2E encrypted, with media support)";
|
|
2127
2389
|
|
|
@@ -2302,7 +2564,11 @@ export default function register(api: any) {
|
|
|
2302
2564
|
const channelRuntime = runtime?.channel;
|
|
2303
2565
|
if (channelRuntime?.routing?.resolveAgentRoute && channelRuntime?.reply?.finalizeInboundContext && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
2304
2566
|
try {
|
|
2305
|
-
|
|
2567
|
+
// Use the read-only runtime config snapshot. loadConfig() is
|
|
2568
|
+
// deprecated (deprecated-internal-config-api guard) and slated
|
|
2569
|
+
// for removal; current() returns a DeepReadonly<OpenClawConfig>
|
|
2570
|
+
// which we only read here (route resolution + memory briefing).
|
|
2571
|
+
const currentCfg = runtime.config.current() as OpenClawConfig;
|
|
2306
2572
|
|
|
2307
2573
|
// 1. Resolve the agent route and session key
|
|
2308
2574
|
const route = channelRuntime.routing.resolveAgentRoute({
|
|
@@ -2328,7 +2594,7 @@ export default function register(api: any) {
|
|
|
2328
2594
|
// sees the current acute state on every inbound, regardless
|
|
2329
2595
|
// of how long the session has been running. CommandBody and
|
|
2330
2596
|
// RawBody stay clean for slash-command parsing.
|
|
2331
|
-
const bodyForAgent = composeBodyForAgent(merged.mergedText, currentCfg);
|
|
2597
|
+
const bodyForAgent = composeBodyForAgent(merged.mergedText, currentCfg, sessionKey);
|
|
2332
2598
|
const msgCtx = channelRuntime.reply.finalizeInboundContext({
|
|
2333
2599
|
Body: merged.mergedText,
|
|
2334
2600
|
RawBody: merged.mergedText,
|
|
@@ -2400,6 +2666,41 @@ export default function register(api: any) {
|
|
|
2400
2666
|
}
|
|
2401
2667
|
}
|
|
2402
2668
|
} else {
|
|
2669
|
+
// Media path (non-audio): if the reply carries image/file media,
|
|
2670
|
+
// send it as a Threema file message. Core provides media as
|
|
2671
|
+
// `mediaUrls` (array, preferred) or legacy `mediaUrl` (string).
|
|
2672
|
+
const mediaUrls: string[] =
|
|
2673
|
+
Array.isArray(payload.mediaUrls) && payload.mediaUrls.length > 0
|
|
2674
|
+
? payload.mediaUrls.filter((u: any) => typeof u === "string" && u.length > 0)
|
|
2675
|
+
: (typeof payload.mediaUrl === "string" && payload.mediaUrl
|
|
2676
|
+
? [payload.mediaUrl]
|
|
2677
|
+
: []);
|
|
2678
|
+
if (mediaUrls.length > 0 && replyClient.isE2EEnabled) {
|
|
2679
|
+
const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
|
|
2680
|
+
let sentAny = false;
|
|
2681
|
+
for (let i = 0; i < mediaUrls.length; i++) {
|
|
2682
|
+
const mu = mediaUrls[i];
|
|
2683
|
+
try {
|
|
2684
|
+
await threemaChannel.outbound.sendMedia({
|
|
2685
|
+
cfg: currentCfg,
|
|
2686
|
+
to: from,
|
|
2687
|
+
text: i === 0 ? (caption ?? "") : "",
|
|
2688
|
+
mediaUrl: mu,
|
|
2689
|
+
});
|
|
2690
|
+
sentAny = true;
|
|
2691
|
+
} catch (mediaErr: any) {
|
|
2692
|
+
api.logger?.error?.(`Threema: media send failed for ${mu}: ${mediaErr?.message}`);
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
// If at least one media went out, we're done (caption rode along).
|
|
2696
|
+
// If ALL media failed, fall through to text so the reply isn't lost.
|
|
2697
|
+
if (sentAny) return;
|
|
2698
|
+
const fallbackText = markdownToThreema(payload.text ?? payload.body);
|
|
2699
|
+
if (fallbackText) {
|
|
2700
|
+
await replyClient.sendE2E(from, fallbackText);
|
|
2701
|
+
}
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2403
2704
|
// Send as text (existing logic)
|
|
2404
2705
|
const text = markdownToThreema(payload.text ?? payload.body);
|
|
2405
2706
|
if (!text) return;
|
|
@@ -2532,7 +2833,9 @@ export default function register(api: any) {
|
|
|
2532
2833
|
&& channelRuntime?.reply?.finalizeInboundContext
|
|
2533
2834
|
&& channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
2534
2835
|
try {
|
|
2535
|
-
|
|
2836
|
+
// Read-only runtime config snapshot (see text path above):
|
|
2837
|
+
// loadConfig() is deprecated and on the removal list.
|
|
2838
|
+
const currentCfg = runtime.config.current() as OpenClawConfig;
|
|
2536
2839
|
|
|
2537
2840
|
// Resolve the same agent route + session key the text path uses
|
|
2538
2841
|
// so file inbounds end up in the live Threema DM session.
|
|
@@ -2553,7 +2856,7 @@ export default function register(api: any) {
|
|
|
2553
2856
|
|
|
2554
2857
|
const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
|
|
2555
2858
|
|
|
2556
|
-
const bodyForAgent = composeBodyForAgent(fileBody, currentCfg);
|
|
2859
|
+
const bodyForAgent = composeBodyForAgent(fileBody, currentCfg, sessionKey);
|
|
2557
2860
|
const fileCtx = channelRuntime.reply.finalizeInboundContext({
|
|
2558
2861
|
Body: fileBody,
|
|
2559
2862
|
RawBody: fileBody,
|
|
@@ -2628,6 +2931,41 @@ export default function register(api: any) {
|
|
|
2628
2931
|
}
|
|
2629
2932
|
}
|
|
2630
2933
|
} else {
|
|
2934
|
+
// Media path (non-audio): if the reply carries image/file media,
|
|
2935
|
+
// send it as a Threema file message. Core provides media as
|
|
2936
|
+
// `mediaUrls` (array, preferred) or legacy `mediaUrl` (string).
|
|
2937
|
+
const mediaUrls: string[] =
|
|
2938
|
+
Array.isArray(payload.mediaUrls) && payload.mediaUrls.length > 0
|
|
2939
|
+
? payload.mediaUrls.filter((u: any) => typeof u === "string" && u.length > 0)
|
|
2940
|
+
: (typeof payload.mediaUrl === "string" && payload.mediaUrl
|
|
2941
|
+
? [payload.mediaUrl]
|
|
2942
|
+
: []);
|
|
2943
|
+
if (mediaUrls.length > 0 && replyClient.isE2EEnabled) {
|
|
2944
|
+
const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
|
|
2945
|
+
let sentAny = false;
|
|
2946
|
+
for (let i = 0; i < mediaUrls.length; i++) {
|
|
2947
|
+
const mu = mediaUrls[i];
|
|
2948
|
+
try {
|
|
2949
|
+
await threemaChannel.outbound.sendMedia({
|
|
2950
|
+
cfg: currentCfg,
|
|
2951
|
+
to: from,
|
|
2952
|
+
text: i === 0 ? (caption ?? "") : "",
|
|
2953
|
+
mediaUrl: mu,
|
|
2954
|
+
});
|
|
2955
|
+
sentAny = true;
|
|
2956
|
+
} catch (mediaErr: any) {
|
|
2957
|
+
api.logger?.error?.(`Threema: media send failed for ${mu}: ${mediaErr?.message}`);
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
// If at least one media went out, we're done (caption rode along).
|
|
2961
|
+
// If ALL media failed, fall through to text so the reply isn't lost.
|
|
2962
|
+
if (sentAny) return;
|
|
2963
|
+
const fallbackText = markdownToThreema(payload.text ?? payload.body);
|
|
2964
|
+
if (fallbackText) {
|
|
2965
|
+
await replyClient.sendE2E(from, fallbackText);
|
|
2966
|
+
}
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2631
2969
|
// Send as text (existing logic)
|
|
2632
2970
|
const text = markdownToThreema(payload.text ?? payload.body);
|
|
2633
2971
|
if (!text) return;
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-threema",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Threema Gateway channel plugin for OpenClaw",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -67,4 +67,4 @@
|
|
|
67
67
|
"type": "git",
|
|
68
68
|
"url": "https://github.com/azrael-solution/openclaw-threema"
|
|
69
69
|
}
|
|
70
|
-
}
|
|
70
|
+
}
|