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 +30 -0
- package/dist/index.js +151 -26
- package/index.ts +144 -29
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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
|
-
//
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
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
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2108
|
-
envelope += `\nSaved to: ${result.filePath}`;
|
|
2135
|
+
const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
|
|
2109
2136
|
|
|
2110
|
-
|
|
2111
|
-
|
|
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
|
-
|
|
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
|
package/openclaw.plugin.json
CHANGED