openclaw-threema 0.6.2 → 0.6.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,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.4 (2026-05-04)
4
+
5
+ ### Fixed
6
+ - v0.6.3 introduced a regression where file inbounds always fell back to
7
+ the legacy `enqueueSystemEvent` path. The new pipeline branch was
8
+ trying to call a non-existent `channelRuntime.reply.resolveDirectSession-
9
+ Key`. The text path doesn't use that helper at all; it uses
10
+ `channelRuntime.routing.resolveAgentRoute` + `buildAgentSessionKey`.
11
+ This release applies the same approach to the file path, so file
12
+ inbounds finally land in the live Threema DM session.
13
+ - Symptom: `Threema file inbound pipeline error: channelRuntime.reply.
14
+ resolveDirectSessionKey is not a function` followed by
15
+ `dispatched via enqueueSystemEvent (fallback)` for every file message.
16
+
17
+ ## 0.6.3 (2026-05-04)
18
+
19
+ ### Fixed
20
+ - **File messages now route through the Channel Inbound Pipeline** like text
21
+ messages, so they appear as part of the same Threema DM conversation
22
+ the agent is already in. Previously file inbounds were dispatched via
23
+ legacy `enqueueSystemEvent` against `agent:main:main`, which left them
24
+ invisible to the running DM session: the agent only learned about the
25
+ file by chance (e.g. by greping the inbound media folder later).
26
+ Symptom hit on 2026-05-04 when the user shipped the OpenClaw
27
+ 2026.5.3 update protocol as a `.txt` file and the agent did not see it
28
+ for ~20 minutes.
29
+
30
+ ### Added
31
+ - File inbounds now expose `MediaPath` / `MediaType` / `MediaUrl` to the
32
+ agent context (matching the convention used by the Matrix channel
33
+ plugin). The agent can read the saved file directly with its normal
34
+ tools (read / pdf / image) and reply in the same DM thread.
35
+ - The memory briefing block is now also injected into file inbounds, so
36
+ the agent's acute-state context applies regardless of whether the
37
+ user sent text or a file.
38
+ - Voice notes (audio file messages) get the same treatment: the
39
+ Whisper transcription is included in the body, the audio path in
40
+ `MediaPath`.
41
+
42
+ ### Compatibility
43
+ - Falls back to the legacy `enqueueSystemEvent` path on older OpenClaw
44
+ runtimes that don't expose `channelRuntime.reply.finalizeInboundContext`,
45
+ or whenever the new pipeline path throws.
46
+
3
47
  ## 0.6.2 (2026-05-04)
4
48
 
5
49
  ### Fixed
package/dist/index.js CHANGED
@@ -1649,37 +1649,173 @@ export default function register(api) {
1649
1649
  api.logger?.debug?.(`Threema file from=${from} messageId=${messageId}`);
1650
1650
  // Process the file: download, decrypt, save, maybe transcribe
1651
1651
  const result = await processFileMessage(client, fileMsg, from, api.logger);
1652
- // Dispatch to OpenClaw
1653
- const enqueue = runtime?.system?.enqueueSystemEvent;
1654
- if (enqueue) {
1655
- let envelope = `[Threema file from ${senderLabel} (${from})]`;
1656
- if (fileMsg.d) {
1657
- envelope += `\nCaption: ${fileMsg.d}`;
1652
+ // Build a body summarising the inbound file. The actual binary
1653
+ // lives at result.filePath; we expose it via MediaPath so the
1654
+ // agent can read/inspect it with its normal tools (read/pdf/image).
1655
+ const fileLines = [];
1656
+ const fileLabel = fileMsg.n || "unnamed";
1657
+ const fileMime = fileMsg.m || "application/octet-stream";
1658
+ const fileSize = typeof fileMsg.s === "number" ? `${fileMsg.s} bytes` : "unknown size";
1659
+ if (fileMsg.d) {
1660
+ fileLines.push(fileMsg.d);
1661
+ }
1662
+ fileLines.push(`[user sent file: ${fileLabel} (${fileMime}, ${fileSize})]`);
1663
+ if (result?.filePath) {
1664
+ fileLines.push(`Saved at: ${result.filePath}`);
1665
+ if (result.transcription) {
1666
+ fileLines.push("");
1667
+ fileLines.push("šŸŽ¤ Audio transcription:");
1668
+ fileLines.push(result.transcription);
1658
1669
  }
1659
- envelope += `\nFile: ${fileMsg.n || "unnamed"} (${fileMsg.m}, ${fileMsg.s} bytes)`;
1660
- if (result?.filePath) {
1661
- envelope += `\nSaved to: ${result.filePath}`;
1662
- if (result.transcription) {
1663
- envelope += `\n\nšŸŽ¤ Audio transcription:\n${result.transcription}`;
1670
+ }
1671
+ else {
1672
+ fileLines.push("āš ļø Failed to download/decrypt file");
1673
+ }
1674
+ const fileBody = fileLines.join("\n");
1675
+ // Try the Channel Inbound Pipeline first (same path as text
1676
+ // messages). This routes the inbound to the existing Threema DM
1677
+ // session, runs the agent in-context, and lets us reply via
1678
+ // dispatchReplyWithBufferedBlockDispatcher just like text.
1679
+ const channelRuntime = runtime?.channel;
1680
+ if (channelRuntime?.routing?.resolveAgentRoute
1681
+ && channelRuntime?.routing?.buildAgentSessionKey
1682
+ && channelRuntime?.reply?.finalizeInboundContext
1683
+ && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
1684
+ try {
1685
+ const currentCfg = runtime.config.loadConfig();
1686
+ // Resolve the same agent route + session key the text path uses
1687
+ // so file inbounds end up in the live Threema DM session.
1688
+ const route = channelRuntime.routing.resolveAgentRoute({
1689
+ cfg: currentCfg,
1690
+ channel: "threema",
1691
+ accountId: "default",
1692
+ peer: { kind: "direct", id: from },
1693
+ });
1694
+ const sessionKey = channelRuntime.routing.buildAgentSessionKey({
1695
+ agentId: route.agentId,
1696
+ channel: "threema",
1697
+ accountId: "default",
1698
+ peer: { kind: "direct", id: from },
1699
+ dmScope: "per-account-channel-peer",
1700
+ });
1701
+ const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
1702
+ const bodyForAgent = composeBodyForAgent(fileBody, currentCfg);
1703
+ const fileCtx = channelRuntime.reply.finalizeInboundContext({
1704
+ Body: fileBody,
1705
+ RawBody: fileBody,
1706
+ CommandBody: fileMsg.d || "",
1707
+ BodyForAgent: bodyForAgent,
1708
+ From: `threema:${from}`,
1709
+ To: `threema:${ownGatewayId}`,
1710
+ SessionKey: sessionKey,
1711
+ AccountId: "default",
1712
+ OriginatingChannel: "threema",
1713
+ OriginatingTo: `threema:${from}`,
1714
+ ChatType: "direct",
1715
+ SenderName: senderLabel,
1716
+ SenderId: from,
1717
+ Provider: "threema",
1718
+ Surface: "threema",
1719
+ ConversationLabel: senderLabel || from,
1720
+ Timestamp: Date.now(),
1721
+ CommandAuthorized: senderAllowed,
1722
+ MediaPath: result?.filePath,
1723
+ MediaUrl: result?.filePath,
1724
+ MediaType: fileMime,
1725
+ MessageSid: messageId,
1726
+ });
1727
+ const replyClient = new ThreemaClient(getThreemaConfig(currentCfg));
1728
+ await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
1729
+ ctx: fileCtx,
1730
+ cfg: currentCfg,
1731
+ dispatcherOptions: {
1732
+ deliver: async (payload) => {
1733
+ const text = payload.text ?? payload.body;
1734
+ if (!text)
1735
+ return;
1736
+ const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
1737
+ if (text.length <= limit) {
1738
+ if (replyClient.isE2EEnabled) {
1739
+ await replyClient.sendE2E(from, text);
1740
+ }
1741
+ else {
1742
+ await replyClient.sendSimple(from, text);
1743
+ }
1744
+ }
1745
+ else {
1746
+ const chunks = [];
1747
+ let remaining = text;
1748
+ while (remaining.length > 0) {
1749
+ if (remaining.length <= limit) {
1750
+ chunks.push(remaining);
1751
+ break;
1752
+ }
1753
+ let splitIdx = remaining.lastIndexOf("\n", limit);
1754
+ if (splitIdx <= 0)
1755
+ splitIdx = limit;
1756
+ chunks.push(remaining.slice(0, splitIdx));
1757
+ remaining = remaining.slice(splitIdx).replace(/^\n/, "");
1758
+ }
1759
+ for (const chunk of chunks) {
1760
+ if (replyClient.isE2EEnabled) {
1761
+ await replyClient.sendE2E(from, chunk);
1762
+ }
1763
+ else {
1764
+ await replyClient.sendSimple(from, chunk);
1765
+ }
1766
+ }
1767
+ }
1768
+ },
1769
+ onReplyStart: () => {
1770
+ api.logger?.info?.(`Threema: agent file-reply started for ${from}`);
1771
+ },
1772
+ },
1773
+ });
1774
+ api.logger?.info?.("Threema file message dispatched via Channel Inbound Pipeline");
1775
+ }
1776
+ catch (pipelineErr) {
1777
+ api.logger?.error?.(`Threema file inbound pipeline error: ${pipelineErr.message}`);
1778
+ // Fallback: legacy enqueueSystemEvent so the file isn't lost.
1779
+ const enqueue = runtime?.system?.enqueueSystemEvent;
1780
+ if (enqueue) {
1781
+ enqueue(`[Threema file from ${senderLabel} (${from})]\n${fileBody}`, {
1782
+ sessionKey: "agent:main:main",
1783
+ deliveryContext: {
1784
+ channel: "threema",
1785
+ to: from,
1786
+ from: from,
1787
+ accountId: "default",
1788
+ mediaPath: result?.filePath,
1789
+ transcription: result?.transcription,
1790
+ },
1791
+ });
1792
+ api.logger?.info?.("Threema file message dispatched via enqueueSystemEvent (fallback)");
1793
+ wakeAgent(config);
1664
1794
  }
1665
1795
  }
1796
+ }
1797
+ else {
1798
+ // Fallback for older OpenClaw runtimes that don't expose the
1799
+ // channel inbound pipeline.
1800
+ const enqueue = runtime?.system?.enqueueSystemEvent;
1801
+ if (enqueue) {
1802
+ enqueue(`[Threema file from ${senderLabel} (${from})]\n${fileBody}`, {
1803
+ sessionKey: "agent:main:main",
1804
+ deliveryContext: {
1805
+ channel: "threema",
1806
+ to: from,
1807
+ from: from,
1808
+ accountId: "default",
1809
+ mediaPath: result?.filePath,
1810
+ transcription: result?.transcription,
1811
+ },
1812
+ });
1813
+ api.logger?.info?.("Threema file message dispatched via enqueueSystemEvent (legacy)");
1814
+ wakeAgent(config);
1815
+ }
1666
1816
  else {
1667
- envelope += `\nāš ļø Failed to download/decrypt file`;
1817
+ api.logger?.warn?.("Threema file: neither channel pipeline nor enqueueSystemEvent available");
1668
1818
  }
1669
- enqueue(envelope, {
1670
- sessionKey: "agent:main:main",
1671
- deliveryContext: {
1672
- channel: "threema",
1673
- to: from,
1674
- from: from,
1675
- accountId: "default",
1676
- mediaPath: result?.filePath,
1677
- transcription: result?.transcription,
1678
- },
1679
- });
1680
- api.logger?.info?.("Threema file message dispatched via enqueueSystemEvent");
1681
- // Wake the agent
1682
- wakeAgent(config);
1683
1819
  }
1684
1820
  }
1685
1821
  else if (decrypted?.type === 0x80) {
package/index.ts CHANGED
@@ -2093,42 +2093,170 @@ export default function register(api: any) {
2093
2093
  api.logger
2094
2094
  );
2095
2095
 
2096
- // Dispatch to OpenClaw
2097
- const enqueue = runtime?.system?.enqueueSystemEvent;
2098
- if (enqueue) {
2099
- let envelope = `[Threema file from ${senderLabel} (${from})]`;
2100
-
2101
- if (fileMsg.d) {
2102
- envelope += `\nCaption: ${fileMsg.d}`;
2096
+ // Build a body summarising the inbound file. The actual binary
2097
+ // lives at result.filePath; we expose it via MediaPath so the
2098
+ // agent can read/inspect it with its normal tools (read/pdf/image).
2099
+ const fileLines: string[] = [];
2100
+ const fileLabel = fileMsg.n || "unnamed";
2101
+ const fileMime = fileMsg.m || "application/octet-stream";
2102
+ const fileSize = typeof fileMsg.s === "number" ? `${fileMsg.s} bytes` : "unknown size";
2103
+ if (fileMsg.d) {
2104
+ fileLines.push(fileMsg.d);
2105
+ }
2106
+ fileLines.push(`[user sent file: ${fileLabel} (${fileMime}, ${fileSize})]`);
2107
+ if (result?.filePath) {
2108
+ fileLines.push(`Saved at: ${result.filePath}`);
2109
+ if (result.transcription) {
2110
+ fileLines.push("");
2111
+ fileLines.push("šŸŽ¤ Audio transcription:");
2112
+ fileLines.push(result.transcription);
2103
2113
  }
2114
+ } else {
2115
+ fileLines.push("āš ļø Failed to download/decrypt file");
2116
+ }
2117
+ const fileBody = fileLines.join("\n");
2118
+
2119
+ // Try the Channel Inbound Pipeline first (same path as text
2120
+ // messages). This routes the inbound to the existing Threema DM
2121
+ // session, runs the agent in-context, and lets us reply via
2122
+ // dispatchReplyWithBufferedBlockDispatcher just like text.
2123
+ const channelRuntime = runtime?.channel;
2124
+ if (channelRuntime?.routing?.resolveAgentRoute
2125
+ && channelRuntime?.routing?.buildAgentSessionKey
2126
+ && channelRuntime?.reply?.finalizeInboundContext
2127
+ && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
2128
+ try {
2129
+ const currentCfg = runtime.config.loadConfig();
2104
2130
 
2105
- envelope += `\nFile: ${fileMsg.n || "unnamed"} (${fileMsg.m}, ${fileMsg.s} bytes)`;
2131
+ // Resolve the same agent route + session key the text path uses
2132
+ // so file inbounds end up in the live Threema DM session.
2133
+ const route = channelRuntime.routing.resolveAgentRoute({
2134
+ cfg: currentCfg,
2135
+ channel: "threema",
2136
+ accountId: "default",
2137
+ peer: { kind: "direct", id: from },
2138
+ });
2106
2139
 
2107
- if (result?.filePath) {
2108
- envelope += `\nSaved to: ${result.filePath}`;
2140
+ const sessionKey = channelRuntime.routing.buildAgentSessionKey({
2141
+ agentId: route.agentId,
2142
+ channel: "threema",
2143
+ accountId: "default",
2144
+ peer: { kind: "direct", id: from },
2145
+ dmScope: "per-account-channel-peer",
2146
+ });
2147
+
2148
+ const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
2149
+
2150
+ const bodyForAgent = composeBodyForAgent(fileBody, currentCfg);
2151
+ const fileCtx = channelRuntime.reply.finalizeInboundContext({
2152
+ Body: fileBody,
2153
+ RawBody: fileBody,
2154
+ CommandBody: fileMsg.d || "",
2155
+ BodyForAgent: bodyForAgent,
2156
+ From: `threema:${from}`,
2157
+ To: `threema:${ownGatewayId}`,
2158
+ SessionKey: sessionKey,
2159
+ AccountId: "default",
2160
+ OriginatingChannel: "threema",
2161
+ OriginatingTo: `threema:${from}`,
2162
+ ChatType: "direct" as const,
2163
+ SenderName: senderLabel,
2164
+ SenderId: from,
2165
+ Provider: "threema",
2166
+ Surface: "threema",
2167
+ ConversationLabel: senderLabel || from,
2168
+ Timestamp: Date.now(),
2169
+ CommandAuthorized: senderAllowed,
2170
+ MediaPath: result?.filePath,
2171
+ MediaUrl: result?.filePath,
2172
+ MediaType: fileMime,
2173
+ MessageSid: messageId,
2174
+ });
2109
2175
 
2110
- if (result.transcription) {
2111
- envelope += `\n\nšŸŽ¤ Audio transcription:\n${result.transcription}`;
2176
+ const replyClient = new ThreemaClient(getThreemaConfig(currentCfg)!);
2177
+ await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
2178
+ ctx: fileCtx,
2179
+ cfg: currentCfg,
2180
+ dispatcherOptions: {
2181
+ deliver: async (payload: any) => {
2182
+ const text = payload.text ?? payload.body;
2183
+ if (!text) return;
2184
+ const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
2185
+ if (text.length <= limit) {
2186
+ if (replyClient.isE2EEnabled) {
2187
+ await replyClient.sendE2E(from, text);
2188
+ } else {
2189
+ await replyClient.sendSimple(from, text);
2190
+ }
2191
+ } else {
2192
+ const chunks: string[] = [];
2193
+ let remaining = text;
2194
+ while (remaining.length > 0) {
2195
+ if (remaining.length <= limit) {
2196
+ chunks.push(remaining);
2197
+ break;
2198
+ }
2199
+ let splitIdx = remaining.lastIndexOf("\n", limit);
2200
+ if (splitIdx <= 0) splitIdx = limit;
2201
+ chunks.push(remaining.slice(0, splitIdx));
2202
+ remaining = remaining.slice(splitIdx).replace(/^\n/, "");
2203
+ }
2204
+ for (const chunk of chunks) {
2205
+ if (replyClient.isE2EEnabled) {
2206
+ await replyClient.sendE2E(from, chunk);
2207
+ } else {
2208
+ await replyClient.sendSimple(from, chunk);
2209
+ }
2210
+ }
2211
+ }
2212
+ },
2213
+ onReplyStart: () => {
2214
+ api.logger?.info?.(`Threema: agent file-reply started for ${from}`);
2215
+ },
2216
+ },
2217
+ });
2218
+ api.logger?.info?.("Threema file message dispatched via Channel Inbound Pipeline");
2219
+ } catch (pipelineErr: any) {
2220
+ api.logger?.error?.(`Threema file inbound pipeline error: ${pipelineErr.message}`);
2221
+ // Fallback: legacy enqueueSystemEvent so the file isn't lost.
2222
+ const enqueue = runtime?.system?.enqueueSystemEvent;
2223
+ if (enqueue) {
2224
+ enqueue(`[Threema file from ${senderLabel} (${from})]\n${fileBody}`, {
2225
+ sessionKey: "agent:main:main",
2226
+ deliveryContext: {
2227
+ channel: "threema",
2228
+ to: from,
2229
+ from: from,
2230
+ accountId: "default",
2231
+ mediaPath: result?.filePath,
2232
+ transcription: result?.transcription,
2233
+ },
2234
+ });
2235
+ api.logger?.info?.("Threema file message dispatched via enqueueSystemEvent (fallback)");
2236
+ wakeAgent(config);
2112
2237
  }
2238
+ }
2239
+ } else {
2240
+ // Fallback for older OpenClaw runtimes that don't expose the
2241
+ // channel inbound pipeline.
2242
+ const enqueue = runtime?.system?.enqueueSystemEvent;
2243
+ if (enqueue) {
2244
+ enqueue(`[Threema file from ${senderLabel} (${from})]\n${fileBody}`, {
2245
+ sessionKey: "agent:main:main",
2246
+ deliveryContext: {
2247
+ channel: "threema",
2248
+ to: from,
2249
+ from: from,
2250
+ accountId: "default",
2251
+ mediaPath: result?.filePath,
2252
+ transcription: result?.transcription,
2253
+ },
2254
+ });
2255
+ api.logger?.info?.("Threema file message dispatched via enqueueSystemEvent (legacy)");
2256
+ wakeAgent(config);
2113
2257
  } else {
2114
- envelope += `\nāš ļø Failed to download/decrypt file`;
2258
+ api.logger?.warn?.("Threema file: neither channel pipeline nor enqueueSystemEvent available");
2115
2259
  }
2116
-
2117
- enqueue(envelope, {
2118
- sessionKey: "agent:main:main",
2119
- deliveryContext: {
2120
- channel: "threema",
2121
- to: from,
2122
- from: from,
2123
- accountId: "default",
2124
- mediaPath: result?.filePath,
2125
- transcription: result?.transcription,
2126
- },
2127
- });
2128
- api.logger?.info?.("Threema file message dispatched via enqueueSystemEvent");
2129
-
2130
- // Wake the agent
2131
- wakeAgent(config);
2132
2260
  }
2133
2261
  } else if (decrypted?.type === 0x80) {
2134
2262
  // Delivery receipt - PII only on debug level
@@ -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.6.2",
5
+ "version": "0.6.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.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Threema Gateway channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",