openclaw-threema 0.6.2 → 0.6.3

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,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.3 (2026-05-04)
4
+
5
+ ### Fixed
6
+ - **File messages now route through the Channel Inbound Pipeline** like text
7
+ messages, so they appear as part of the same Threema DM conversation
8
+ the agent is already in. Previously file inbounds were dispatched via
9
+ legacy `enqueueSystemEvent` against `agent:main:main`, which left them
10
+ invisible to the running DM session: the agent only learned about the
11
+ file by chance (e.g. by greping the inbound media folder later).
12
+ Symptom hit on 2026-05-04 when the user shipped the OpenClaw
13
+ 2026.5.3 update protocol as a `.txt` file and the agent did not see it
14
+ for ~20 minutes.
15
+
16
+ ### Added
17
+ - File inbounds now expose `MediaPath` / `MediaType` / `MediaUrl` to the
18
+ agent context (matching the convention used by the Matrix channel
19
+ plugin). The agent can read the saved file directly with its normal
20
+ tools (read / pdf / image) and reply in the same DM thread.
21
+ - The memory briefing block is now also injected into file inbounds, so
22
+ the agent's acute-state context applies regardless of whether the
23
+ user sent text or a file.
24
+ - Voice notes (audio file messages) get the same treatment: the
25
+ Whisper transcription is included in the body, the audio path in
26
+ `MediaPath`.
27
+
28
+ ### Compatibility
29
+ - Falls back to the legacy `enqueueSystemEvent` path on older OpenClaw
30
+ runtimes that don't expose `channelRuntime.reply.finalizeInboundContext`,
31
+ or whenever the new pipeline path throws.
32
+
3
33
  ## 0.6.2 (2026-05-04)
4
34
 
5
35
  ### Fixed
package/dist/index.js CHANGED
@@ -1649,37 +1649,162 @@ 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?.reply?.finalizeInboundContext
1681
+ && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
1682
+ try {
1683
+ const currentCfg = runtime.config.loadConfig();
1684
+ const sessionKey = channelRuntime.reply.resolveDirectSessionKey({
1685
+ channel: "threema",
1686
+ accountId: "default",
1687
+ peer: { kind: "direct", id: from },
1688
+ dmScope: "per-account-channel-peer",
1689
+ });
1690
+ const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
1691
+ const bodyForAgent = composeBodyForAgent(fileBody, currentCfg);
1692
+ const fileCtx = channelRuntime.reply.finalizeInboundContext({
1693
+ Body: fileBody,
1694
+ RawBody: fileBody,
1695
+ CommandBody: fileMsg.d || "",
1696
+ BodyForAgent: bodyForAgent,
1697
+ From: `threema:${from}`,
1698
+ To: `threema:${ownGatewayId}`,
1699
+ SessionKey: sessionKey,
1700
+ AccountId: "default",
1701
+ OriginatingChannel: "threema",
1702
+ OriginatingTo: `threema:${from}`,
1703
+ ChatType: "direct",
1704
+ SenderName: senderLabel,
1705
+ SenderId: from,
1706
+ Provider: "threema",
1707
+ Surface: "threema",
1708
+ ConversationLabel: senderLabel || from,
1709
+ Timestamp: Date.now(),
1710
+ CommandAuthorized: senderAllowed,
1711
+ MediaPath: result?.filePath,
1712
+ MediaUrl: result?.filePath,
1713
+ MediaType: fileMime,
1714
+ MessageSid: messageId,
1715
+ });
1716
+ const replyClient = new ThreemaClient(getThreemaConfig(currentCfg));
1717
+ await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
1718
+ ctx: fileCtx,
1719
+ cfg: currentCfg,
1720
+ dispatcherOptions: {
1721
+ deliver: async (payload) => {
1722
+ const text = payload.text ?? payload.body;
1723
+ if (!text)
1724
+ return;
1725
+ const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
1726
+ if (text.length <= limit) {
1727
+ if (replyClient.isE2EEnabled) {
1728
+ await replyClient.sendE2E(from, text);
1729
+ }
1730
+ else {
1731
+ await replyClient.sendSimple(from, text);
1732
+ }
1733
+ }
1734
+ else {
1735
+ const chunks = [];
1736
+ let remaining = text;
1737
+ while (remaining.length > 0) {
1738
+ if (remaining.length <= limit) {
1739
+ chunks.push(remaining);
1740
+ break;
1741
+ }
1742
+ let splitIdx = remaining.lastIndexOf("\n", limit);
1743
+ if (splitIdx <= 0)
1744
+ splitIdx = limit;
1745
+ chunks.push(remaining.slice(0, splitIdx));
1746
+ remaining = remaining.slice(splitIdx).replace(/^\n/, "");
1747
+ }
1748
+ for (const chunk of chunks) {
1749
+ if (replyClient.isE2EEnabled) {
1750
+ await replyClient.sendE2E(from, chunk);
1751
+ }
1752
+ else {
1753
+ await replyClient.sendSimple(from, chunk);
1754
+ }
1755
+ }
1756
+ }
1757
+ },
1758
+ onReplyStart: () => {
1759
+ api.logger?.info?.(`Threema: agent file-reply started for ${from}`);
1760
+ },
1761
+ },
1762
+ });
1763
+ api.logger?.info?.("Threema file message dispatched via Channel Inbound Pipeline");
1764
+ }
1765
+ catch (pipelineErr) {
1766
+ api.logger?.error?.(`Threema file inbound pipeline error: ${pipelineErr.message}`);
1767
+ // Fallback: legacy enqueueSystemEvent so the file isn't lost.
1768
+ const enqueue = runtime?.system?.enqueueSystemEvent;
1769
+ if (enqueue) {
1770
+ enqueue(`[Threema file from ${senderLabel} (${from})]\n${fileBody}`, {
1771
+ sessionKey: "agent:main:main",
1772
+ deliveryContext: {
1773
+ channel: "threema",
1774
+ to: from,
1775
+ from: from,
1776
+ accountId: "default",
1777
+ mediaPath: result?.filePath,
1778
+ transcription: result?.transcription,
1779
+ },
1780
+ });
1781
+ api.logger?.info?.("Threema file message dispatched via enqueueSystemEvent (fallback)");
1782
+ wakeAgent(config);
1664
1783
  }
1665
1784
  }
1785
+ }
1786
+ else {
1787
+ // Fallback for older OpenClaw runtimes that don't expose the
1788
+ // channel inbound pipeline.
1789
+ const enqueue = runtime?.system?.enqueueSystemEvent;
1790
+ if (enqueue) {
1791
+ enqueue(`[Threema file from ${senderLabel} (${from})]\n${fileBody}`, {
1792
+ sessionKey: "agent:main:main",
1793
+ deliveryContext: {
1794
+ channel: "threema",
1795
+ to: from,
1796
+ from: from,
1797
+ accountId: "default",
1798
+ mediaPath: result?.filePath,
1799
+ transcription: result?.transcription,
1800
+ },
1801
+ });
1802
+ api.logger?.info?.("Threema file message dispatched via enqueueSystemEvent (legacy)");
1803
+ wakeAgent(config);
1804
+ }
1666
1805
  else {
1667
- envelope += `\nāš ļø Failed to download/decrypt file`;
1806
+ api.logger?.warn?.("Threema file: neither channel pipeline nor enqueueSystemEvent available");
1668
1807
  }
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
1808
  }
1684
1809
  }
1685
1810
  else if (decrypted?.type === 0x80) {
package/index.ts CHANGED
@@ -2093,42 +2093,157 @@ 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");
2104
2118
 
2105
- envelope += `\nFile: ${fileMsg.n || "unnamed"} (${fileMsg.m}, ${fileMsg.s} bytes)`;
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?.reply?.finalizeInboundContext
2125
+ && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
2126
+ try {
2127
+ const currentCfg = runtime.config.loadConfig();
2128
+ const sessionKey = channelRuntime.reply.resolveDirectSessionKey({
2129
+ channel: "threema",
2130
+ accountId: "default",
2131
+ peer: { kind: "direct", id: from },
2132
+ dmScope: "per-account-channel-peer",
2133
+ });
2106
2134
 
2107
- if (result?.filePath) {
2108
- envelope += `\nSaved to: ${result.filePath}`;
2135
+ const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
2109
2136
 
2110
- if (result.transcription) {
2111
- envelope += `\n\nšŸŽ¤ Audio transcription:\n${result.transcription}`;
2137
+ const bodyForAgent = composeBodyForAgent(fileBody, currentCfg);
2138
+ const fileCtx = channelRuntime.reply.finalizeInboundContext({
2139
+ Body: fileBody,
2140
+ RawBody: fileBody,
2141
+ CommandBody: fileMsg.d || "",
2142
+ BodyForAgent: bodyForAgent,
2143
+ From: `threema:${from}`,
2144
+ To: `threema:${ownGatewayId}`,
2145
+ SessionKey: sessionKey,
2146
+ AccountId: "default",
2147
+ OriginatingChannel: "threema",
2148
+ OriginatingTo: `threema:${from}`,
2149
+ ChatType: "direct" as const,
2150
+ SenderName: senderLabel,
2151
+ SenderId: from,
2152
+ Provider: "threema",
2153
+ Surface: "threema",
2154
+ ConversationLabel: senderLabel || from,
2155
+ Timestamp: Date.now(),
2156
+ CommandAuthorized: senderAllowed,
2157
+ MediaPath: result?.filePath,
2158
+ MediaUrl: result?.filePath,
2159
+ MediaType: fileMime,
2160
+ MessageSid: messageId,
2161
+ });
2162
+
2163
+ const replyClient = new ThreemaClient(getThreemaConfig(currentCfg)!);
2164
+ await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
2165
+ ctx: fileCtx,
2166
+ cfg: currentCfg,
2167
+ dispatcherOptions: {
2168
+ deliver: async (payload: any) => {
2169
+ const text = payload.text ?? payload.body;
2170
+ if (!text) return;
2171
+ const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
2172
+ if (text.length <= limit) {
2173
+ if (replyClient.isE2EEnabled) {
2174
+ await replyClient.sendE2E(from, text);
2175
+ } else {
2176
+ await replyClient.sendSimple(from, text);
2177
+ }
2178
+ } else {
2179
+ const chunks: string[] = [];
2180
+ let remaining = text;
2181
+ while (remaining.length > 0) {
2182
+ if (remaining.length <= limit) {
2183
+ chunks.push(remaining);
2184
+ break;
2185
+ }
2186
+ let splitIdx = remaining.lastIndexOf("\n", limit);
2187
+ if (splitIdx <= 0) splitIdx = limit;
2188
+ chunks.push(remaining.slice(0, splitIdx));
2189
+ remaining = remaining.slice(splitIdx).replace(/^\n/, "");
2190
+ }
2191
+ for (const chunk of chunks) {
2192
+ if (replyClient.isE2EEnabled) {
2193
+ await replyClient.sendE2E(from, chunk);
2194
+ } else {
2195
+ await replyClient.sendSimple(from, chunk);
2196
+ }
2197
+ }
2198
+ }
2199
+ },
2200
+ onReplyStart: () => {
2201
+ api.logger?.info?.(`Threema: agent file-reply started for ${from}`);
2202
+ },
2203
+ },
2204
+ });
2205
+ api.logger?.info?.("Threema file message dispatched via Channel Inbound Pipeline");
2206
+ } catch (pipelineErr: any) {
2207
+ api.logger?.error?.(`Threema file inbound pipeline error: ${pipelineErr.message}`);
2208
+ // Fallback: legacy enqueueSystemEvent so the file isn't lost.
2209
+ const enqueue = runtime?.system?.enqueueSystemEvent;
2210
+ if (enqueue) {
2211
+ enqueue(`[Threema file from ${senderLabel} (${from})]\n${fileBody}`, {
2212
+ sessionKey: "agent:main:main",
2213
+ deliveryContext: {
2214
+ channel: "threema",
2215
+ to: from,
2216
+ from: from,
2217
+ accountId: "default",
2218
+ mediaPath: result?.filePath,
2219
+ transcription: result?.transcription,
2220
+ },
2221
+ });
2222
+ api.logger?.info?.("Threema file message dispatched via enqueueSystemEvent (fallback)");
2223
+ wakeAgent(config);
2112
2224
  }
2225
+ }
2226
+ } else {
2227
+ // Fallback for older OpenClaw runtimes that don't expose the
2228
+ // channel inbound pipeline.
2229
+ const enqueue = runtime?.system?.enqueueSystemEvent;
2230
+ if (enqueue) {
2231
+ enqueue(`[Threema file from ${senderLabel} (${from})]\n${fileBody}`, {
2232
+ sessionKey: "agent:main:main",
2233
+ deliveryContext: {
2234
+ channel: "threema",
2235
+ to: from,
2236
+ from: from,
2237
+ accountId: "default",
2238
+ mediaPath: result?.filePath,
2239
+ transcription: result?.transcription,
2240
+ },
2241
+ });
2242
+ api.logger?.info?.("Threema file message dispatched via enqueueSystemEvent (legacy)");
2243
+ wakeAgent(config);
2113
2244
  } else {
2114
- envelope += `\nāš ļø Failed to download/decrypt file`;
2245
+ api.logger?.warn?.("Threema file: neither channel pipeline nor enqueueSystemEvent available");
2115
2246
  }
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
2247
  }
2133
2248
  } else if (decrypted?.type === 0x80) {
2134
2249
  // 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.3",
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.3",
4
4
  "description": "Threema Gateway channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",