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 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 { decodeUTF8 } from "tweetnacl-util";
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
- function buildMemoryBriefing(cfg) {
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
- if (!currentState && !acutePending) {
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("This snapshot is appended to every inbound Threema message so the agent has a fresh view of the user's acute state, even in long-running sessions. Read it before replying.");
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
- function composeBodyForAgent(userText, cfg) {
154
- const briefing = buildMemoryBriefing(cfg);
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.6.7";
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
- const currentCfg = runtime.config.loadConfig();
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
- const currentCfg = runtime.config.loadConfig();
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 { decodeUTF8 } from "tweetnacl-util";
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
- function buildMemoryBriefing(cfg: OpenClawConfig | undefined): string {
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
- "This snapshot is appended to every inbound Threema message so the agent has a fresh view of the user's acute state, even in long-running sessions. Read it before replying."
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
- function composeBodyForAgent(userText: string, cfg: OpenClawConfig | undefined): string {
303
- const briefing = buildMemoryBriefing(cfg);
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.6.7";
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
- const currentCfg = runtime.config.loadConfig();
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
- const currentCfg = runtime.config.loadConfig();
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;
@@ -2,7 +2,7 @@
2
2
  "id": "threema",
3
3
  "name": "Threema Gateway",
4
4
  "description": "Threema messaging channel via Threema Gateway API (E2E encrypted)",
5
- "version": "0.7.0",
5
+ "version": "0.7.4",
6
6
  "channels": [
7
7
  "threema"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-threema",
3
- "version": "0.7.0",
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
+ }