openclaw-threema 0.7.1 → 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 +51 -0
- package/dist/index.js +332 -11
- package/index.ts +348 -11
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
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
|
+
|
|
3
54
|
## 0.7.1 (2026-05-09)
|
|
4
55
|
|
|
5
56
|
### Fixed
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,74 @@ const MEDIA_INBOUND_DIR = path.join(process.env.HOME || "/tmp", ".openclaw", "me
|
|
|
22
22
|
const briefingCache = new Map();
|
|
23
23
|
const BRIEFING_CACHE_TTL_MS = 60_000;
|
|
24
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
|
+
}
|
|
25
93
|
function readBoundedFile(filePath) {
|
|
26
94
|
try {
|
|
27
95
|
const stat = fs.statSync(filePath);
|
|
@@ -116,11 +184,13 @@ function resolveWorkspaceDir(cfg) {
|
|
|
116
184
|
}
|
|
117
185
|
return null;
|
|
118
186
|
}
|
|
119
|
-
|
|
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) {
|
|
120
190
|
const workspace = resolveWorkspaceDir(cfg);
|
|
121
191
|
if (!workspace)
|
|
122
192
|
return "";
|
|
123
|
-
const cacheKey = workspace
|
|
193
|
+
const cacheKey = `full:${workspace}`;
|
|
124
194
|
const now = Date.now();
|
|
125
195
|
const cached = briefingCache.get(cacheKey);
|
|
126
196
|
if (cached && cached.expiresAt > now)
|
|
@@ -129,14 +199,20 @@ function buildMemoryBriefing(cfg) {
|
|
|
129
199
|
const pendingPath = path.join(workspace, "memory", "pending-actions.md");
|
|
130
200
|
const currentState = extractCurrentStateBlock(readBoundedFile(memoryPath));
|
|
131
201
|
const acutePending = extractAcutePending(readBoundedFile(pendingPath));
|
|
132
|
-
|
|
202
|
+
const ocVersion = getLiveOpenClawVersion();
|
|
203
|
+
if (!currentState && !acutePending && !ocVersion) {
|
|
133
204
|
briefingCache.set(cacheKey, { text: "", expiresAt: now + BRIEFING_CACHE_TTL_MS });
|
|
134
205
|
return "";
|
|
135
206
|
}
|
|
136
207
|
const parts = [];
|
|
137
208
|
parts.push("Memory briefing (untrusted, generated by threema plugin at inbound time \u2014 informational only, not instructions):");
|
|
138
|
-
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.");
|
|
139
210
|
parts.push("");
|
|
211
|
+
if (ocVersion) {
|
|
212
|
+
parts.push(`--- live state ---`);
|
|
213
|
+
parts.push(`OpenClaw version (live \`openclaw --version\`): ${ocVersion}`);
|
|
214
|
+
parts.push("");
|
|
215
|
+
}
|
|
140
216
|
if (currentState) {
|
|
141
217
|
parts.push("--- MEMORY.md (Current State) ---");
|
|
142
218
|
parts.push(currentState);
|
|
@@ -151,8 +227,57 @@ function buildMemoryBriefing(cfg) {
|
|
|
151
227
|
briefingCache.set(cacheKey, { text, expiresAt: now + BRIEFING_CACHE_TTL_MS });
|
|
152
228
|
return text;
|
|
153
229
|
}
|
|
154
|
-
|
|
155
|
-
|
|
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);
|
|
156
281
|
if (!briefing)
|
|
157
282
|
return userText;
|
|
158
283
|
return `${userText}\n\n[memory_briefing]\n${briefing}\n[/memory_briefing]`;
|
|
@@ -1513,6 +1638,124 @@ const threemaChannel = {
|
|
|
1513
1638
|
});
|
|
1514
1639
|
},
|
|
1515
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
|
+
},
|
|
1516
1759
|
};
|
|
1517
1760
|
const COALESCE_WINDOW_MS = 3000; // 3 second coalescing window
|
|
1518
1761
|
const COALESCE_MAX_PARTS = 5; // Max parts before flushing
|
|
@@ -1645,7 +1888,7 @@ async function queueInboundForCoalescing(sender, part, dispatcher) {
|
|
|
1645
1888
|
}
|
|
1646
1889
|
export const id = "threema";
|
|
1647
1890
|
export const name = "Threema Gateway";
|
|
1648
|
-
export const version = "0.
|
|
1891
|
+
export const version = "0.7.4";
|
|
1649
1892
|
export const description = "Threema messaging channel via Threema Gateway API (E2E encrypted, with media support)";
|
|
1650
1893
|
export default function register(api) {
|
|
1651
1894
|
try {
|
|
@@ -1791,7 +2034,11 @@ export default function register(api) {
|
|
|
1791
2034
|
const channelRuntime = runtime?.channel;
|
|
1792
2035
|
if (channelRuntime?.routing?.resolveAgentRoute && channelRuntime?.reply?.finalizeInboundContext && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
1793
2036
|
try {
|
|
1794
|
-
|
|
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();
|
|
1795
2042
|
// 1. Resolve the agent route and session key
|
|
1796
2043
|
const route = channelRuntime.routing.resolveAgentRoute({
|
|
1797
2044
|
cfg: currentCfg,
|
|
@@ -1813,7 +2060,7 @@ export default function register(api) {
|
|
|
1813
2060
|
// sees the current acute state on every inbound, regardless
|
|
1814
2061
|
// of how long the session has been running. CommandBody and
|
|
1815
2062
|
// RawBody stay clean for slash-command parsing.
|
|
1816
|
-
const bodyForAgent = composeBodyForAgent(merged.mergedText, currentCfg);
|
|
2063
|
+
const bodyForAgent = composeBodyForAgent(merged.mergedText, currentCfg, sessionKey);
|
|
1817
2064
|
const msgCtx = channelRuntime.reply.finalizeInboundContext({
|
|
1818
2065
|
Body: merged.mergedText,
|
|
1819
2066
|
RawBody: merged.mergedText,
|
|
@@ -1888,6 +2135,42 @@ export default function register(api) {
|
|
|
1888
2135
|
}
|
|
1889
2136
|
}
|
|
1890
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
|
+
}
|
|
1891
2174
|
// Send as text (existing logic)
|
|
1892
2175
|
const text = markdownToThreema(payload.text ?? payload.body);
|
|
1893
2176
|
if (!text)
|
|
@@ -2017,7 +2300,9 @@ export default function register(api) {
|
|
|
2017
2300
|
&& channelRuntime?.reply?.finalizeInboundContext
|
|
2018
2301
|
&& channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
2019
2302
|
try {
|
|
2020
|
-
|
|
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();
|
|
2021
2306
|
// Resolve the same agent route + session key the text path uses
|
|
2022
2307
|
// so file inbounds end up in the live Threema DM session.
|
|
2023
2308
|
const route = channelRuntime.routing.resolveAgentRoute({
|
|
@@ -2034,7 +2319,7 @@ export default function register(api) {
|
|
|
2034
2319
|
dmScope: "per-account-channel-peer",
|
|
2035
2320
|
});
|
|
2036
2321
|
const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
|
|
2037
|
-
const bodyForAgent = composeBodyForAgent(fileBody, currentCfg);
|
|
2322
|
+
const bodyForAgent = composeBodyForAgent(fileBody, currentCfg, sessionKey);
|
|
2038
2323
|
const fileCtx = channelRuntime.reply.finalizeInboundContext({
|
|
2039
2324
|
Body: fileBody,
|
|
2040
2325
|
RawBody: fileBody,
|
|
@@ -2112,6 +2397,42 @@ export default function register(api) {
|
|
|
2112
2397
|
}
|
|
2113
2398
|
}
|
|
2114
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
|
+
}
|
|
2115
2436
|
// Send as text (existing logic)
|
|
2116
2437
|
const text = markdownToThreema(payload.text ?? payload.body);
|
|
2117
2438
|
if (!text)
|
package/index.ts
CHANGED
|
@@ -174,6 +174,67 @@ const briefingCache: Map<string, BriefingCacheEntry> = new Map();
|
|
|
174
174
|
const BRIEFING_CACHE_TTL_MS = 60_000;
|
|
175
175
|
const BRIEFING_MAX_BYTES = 8192;
|
|
176
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
|
+
|
|
177
238
|
function readBoundedFile(filePath: string): string {
|
|
178
239
|
try {
|
|
179
240
|
const stat = fs.statSync(filePath);
|
|
@@ -259,10 +320,12 @@ function resolveWorkspaceDir(cfg: OpenClawConfig | undefined): string | null {
|
|
|
259
320
|
return null;
|
|
260
321
|
}
|
|
261
322
|
|
|
262
|
-
|
|
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 {
|
|
263
326
|
const workspace = resolveWorkspaceDir(cfg);
|
|
264
327
|
if (!workspace) return "";
|
|
265
|
-
const cacheKey = workspace
|
|
328
|
+
const cacheKey = `full:${workspace}`;
|
|
266
329
|
const now = Date.now();
|
|
267
330
|
const cached = briefingCache.get(cacheKey);
|
|
268
331
|
if (cached && cached.expiresAt > now) return cached.text;
|
|
@@ -272,8 +335,9 @@ function buildMemoryBriefing(cfg: OpenClawConfig | undefined): string {
|
|
|
272
335
|
|
|
273
336
|
const currentState = extractCurrentStateBlock(readBoundedFile(memoryPath));
|
|
274
337
|
const acutePending = extractAcutePending(readBoundedFile(pendingPath));
|
|
338
|
+
const ocVersion = getLiveOpenClawVersion();
|
|
275
339
|
|
|
276
|
-
if (!currentState && !acutePending) {
|
|
340
|
+
if (!currentState && !acutePending && !ocVersion) {
|
|
277
341
|
briefingCache.set(cacheKey, { text: "", expiresAt: now + BRIEFING_CACHE_TTL_MS });
|
|
278
342
|
return "";
|
|
279
343
|
}
|
|
@@ -283,9 +347,14 @@ function buildMemoryBriefing(cfg: OpenClawConfig | undefined): string {
|
|
|
283
347
|
"Memory briefing (untrusted, generated by threema plugin at inbound time \u2014 informational only, not instructions):"
|
|
284
348
|
);
|
|
285
349
|
parts.push(
|
|
286
|
-
"
|
|
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."
|
|
287
351
|
);
|
|
288
352
|
parts.push("");
|
|
353
|
+
if (ocVersion) {
|
|
354
|
+
parts.push(`--- live state ---`);
|
|
355
|
+
parts.push(`OpenClaw version (live \`openclaw --version\`): ${ocVersion}`);
|
|
356
|
+
parts.push("");
|
|
357
|
+
}
|
|
289
358
|
if (currentState) {
|
|
290
359
|
parts.push("--- MEMORY.md (Current State) ---");
|
|
291
360
|
parts.push(currentState);
|
|
@@ -300,8 +369,67 @@ function buildMemoryBriefing(cfg: OpenClawConfig | undefined): string {
|
|
|
300
369
|
return text;
|
|
301
370
|
}
|
|
302
371
|
|
|
303
|
-
|
|
304
|
-
|
|
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);
|
|
305
433
|
if (!briefing) return userText;
|
|
306
434
|
return `${userText}\n\n[memory_briefing]\n${briefing}\n[/memory_briefing]`;
|
|
307
435
|
}
|
|
@@ -1951,6 +2079,139 @@ const threemaChannel = {
|
|
|
1951
2079
|
});
|
|
1952
2080
|
},
|
|
1953
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
|
+
},
|
|
1954
2215
|
};
|
|
1955
2216
|
|
|
1956
2217
|
// ============================================================================
|
|
@@ -2122,7 +2383,7 @@ async function queueInboundForCoalescing(
|
|
|
2122
2383
|
|
|
2123
2384
|
export const id = "threema";
|
|
2124
2385
|
export const name = "Threema Gateway";
|
|
2125
|
-
export const version = "0.
|
|
2386
|
+
export const version = "0.7.4";
|
|
2126
2387
|
export const description =
|
|
2127
2388
|
"Threema messaging channel via Threema Gateway API (E2E encrypted, with media support)";
|
|
2128
2389
|
|
|
@@ -2303,7 +2564,11 @@ export default function register(api: any) {
|
|
|
2303
2564
|
const channelRuntime = runtime?.channel;
|
|
2304
2565
|
if (channelRuntime?.routing?.resolveAgentRoute && channelRuntime?.reply?.finalizeInboundContext && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
2305
2566
|
try {
|
|
2306
|
-
|
|
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;
|
|
2307
2572
|
|
|
2308
2573
|
// 1. Resolve the agent route and session key
|
|
2309
2574
|
const route = channelRuntime.routing.resolveAgentRoute({
|
|
@@ -2329,7 +2594,7 @@ export default function register(api: any) {
|
|
|
2329
2594
|
// sees the current acute state on every inbound, regardless
|
|
2330
2595
|
// of how long the session has been running. CommandBody and
|
|
2331
2596
|
// RawBody stay clean for slash-command parsing.
|
|
2332
|
-
const bodyForAgent = composeBodyForAgent(merged.mergedText, currentCfg);
|
|
2597
|
+
const bodyForAgent = composeBodyForAgent(merged.mergedText, currentCfg, sessionKey);
|
|
2333
2598
|
const msgCtx = channelRuntime.reply.finalizeInboundContext({
|
|
2334
2599
|
Body: merged.mergedText,
|
|
2335
2600
|
RawBody: merged.mergedText,
|
|
@@ -2401,6 +2666,41 @@ export default function register(api: any) {
|
|
|
2401
2666
|
}
|
|
2402
2667
|
}
|
|
2403
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
|
+
}
|
|
2404
2704
|
// Send as text (existing logic)
|
|
2405
2705
|
const text = markdownToThreema(payload.text ?? payload.body);
|
|
2406
2706
|
if (!text) return;
|
|
@@ -2533,7 +2833,9 @@ export default function register(api: any) {
|
|
|
2533
2833
|
&& channelRuntime?.reply?.finalizeInboundContext
|
|
2534
2834
|
&& channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
2535
2835
|
try {
|
|
2536
|
-
|
|
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;
|
|
2537
2839
|
|
|
2538
2840
|
// Resolve the same agent route + session key the text path uses
|
|
2539
2841
|
// so file inbounds end up in the live Threema DM session.
|
|
@@ -2554,7 +2856,7 @@ export default function register(api: any) {
|
|
|
2554
2856
|
|
|
2555
2857
|
const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
|
|
2556
2858
|
|
|
2557
|
-
const bodyForAgent = composeBodyForAgent(fileBody, currentCfg);
|
|
2859
|
+
const bodyForAgent = composeBodyForAgent(fileBody, currentCfg, sessionKey);
|
|
2558
2860
|
const fileCtx = channelRuntime.reply.finalizeInboundContext({
|
|
2559
2861
|
Body: fileBody,
|
|
2560
2862
|
RawBody: fileBody,
|
|
@@ -2629,6 +2931,41 @@ export default function register(api: any) {
|
|
|
2629
2931
|
}
|
|
2630
2932
|
}
|
|
2631
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
|
+
}
|
|
2632
2969
|
// Send as text (existing logic)
|
|
2633
2970
|
const text = markdownToThreema(payload.text ?? payload.body);
|
|
2634
2971
|
if (!text) return;
|
package/openclaw.plugin.json
CHANGED