openclaw-threema 0.5.2 → 0.6.1

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @openclaw/threema
1
+ # openclaw-threema
2
2
 
3
3
  Threema Gateway channel plugin for [OpenClaw](https://github.com/openclaw/openclaw) — privacy-focused E2E encrypted messaging via the [Threema Gateway API](https://gateway.threema.ch/).
4
4
 
@@ -8,24 +8,25 @@ Threema Gateway channel plugin for [OpenClaw](https://github.com/openclaw/opencl
8
8
  - **Media send/receive** — images, files, audio (E2E encrypted blobs)
9
9
  - **Voice transcription** — automatic speech-to-text via local Whisper
10
10
  - **Instant wake** — webhook-based message delivery (no polling)
11
+ - **Slash commands** — `/status`, `/compact`, etc. work natively via Threema (v0.6.0+)
11
12
  - **CLI tools** — `openclaw threema send|send-file|status|keygen`
12
13
 
13
14
  ## Requirements
14
15
 
15
16
  - A [Threema Gateway](https://gateway.threema.ch/) account (E2E mode)
16
- - OpenClaw v0.30+ with channel plugin support
17
+ - OpenClaw 2026.3.2+ with channel plugin support
17
18
  - For voice transcription: [OpenAI Whisper](https://github.com/openai/whisper) installed locally
18
19
 
19
- Please keep in mind that the use of the Threema Gateway is not for free.
20
- At the time of writing these lines you have to pay 1.600 "Credits" to get an ID.
21
- Every Message costs another Credit (roughly EUR 0,02).
22
- 2.500 Credits are about EUR 55,00
20
+ > **Note:** Threema Gateway usage is not free. At the time of writing, you need ~1,600 credits to register a Gateway ID, and each message costs 1 credit (~€0.02). 2,500 credits cost approximately €55.
23
21
 
24
22
  ## Installation
25
23
 
26
24
  ```bash
27
- # From npm (when published)
28
- npm install @openclaw/threema
25
+ # From npm
26
+ npm install openclaw-threema
27
+
28
+ # From ClawHub
29
+ openclaw plugins install clawhub:threema
29
30
 
30
31
  # Or as a local extension
31
32
  cp -r . ~/.openclaw/extensions/threema/
@@ -77,7 +78,7 @@ https://your-host:18789/threema/webhook
77
78
 
78
79
  The default port is `18789` (OpenClaw Gateway). The path matches `webhookPath` in your config (default: `/threema/webhook`).
79
80
 
80
- **Note:** If you're behind a reverse proxy, adjust the URL accordingly. The plugin registers the endpoint at the configured `webhookPath`.
81
+ **Note:** If you're behind a reverse proxy (Cloudflare Tunnel, nginx, etc.), adjust the URL accordingly. The plugin registers the endpoint at the configured `webhookPath`.
81
82
 
82
83
  ### 3. Restart OpenClaw
83
84
 
@@ -96,7 +97,7 @@ openclaw threema send ABCD1234 "Hello from OpenClaw!"
96
97
 
97
98
  | Policy | Description |
98
99
  |--------|-------------|
99
- | `allowlist` | Only IDs in `allowFrom` array (default) |
100
+ | `allowlist` | Only IDs in `allowFrom` array (default, recommended) |
100
101
  | `open` | Accept from anyone |
101
102
  | `disabled` | Reject all DMs |
102
103
 
@@ -104,7 +105,7 @@ openclaw threema send ABCD1234 "Hello from OpenClaw!"
104
105
 
105
106
  When a voice message is received, the plugin automatically transcribes it using local Whisper (no API key needed). The transcription is included in the message delivered to the agent.
106
107
 
107
- Whisper must be installed and accessible in PATH (e.g., via `pip install openai-whisper` or Homebrew).
108
+ Whisper must be installed and accessible in PATH (e.g., `pip install openai-whisper`).
108
109
 
109
110
  ## Message Types Supported
110
111
 
@@ -119,8 +120,26 @@ Whisper must be installed and accessible in PATH (e.g., via `pip install openai-
119
120
  - Private keys never leave the host
120
121
  - Webhook verification via HMAC-SHA256 (mandatory, verified before decryption)
121
122
  - SSRF protection: redirect blocking, DNS rebinding checks, private IP filtering
123
+ - Secrets are redacted in all log output
124
+
125
+ ## Changelog
126
+
127
+ ### v0.6.0 (2026-04-17)
128
+ - **Channel Inbound Pipeline** — messages now go through OpenClaw's native channel pipeline instead of raw `enqueueSystemEvent`. This enables slash commands (`/status`, `/compact`, etc.) directly from Threema.
129
+ - **Graceful fallback** — automatically falls back to `enqueueSystemEvent` if the channel pipeline is unavailable (e.g., older OpenClaw versions).
130
+ - **Long message chunking** — replies exceeding 3,500 chars are split at newline boundaries.
131
+
132
+ ### v0.5.2 (2026-03-30)
133
+ - OpenClaw 2026.3.2 compatibility fixes
134
+ - Health monitor improvements
135
+ - ClawHub publishing support (`compat.pluginApi`, `build.openclawVersion`)
136
+
137
+ ### v0.4.5 (2026-02-17)
138
+ - Initial npm release
139
+ - Full E2E text + media support
140
+ - Voice transcription via Whisper
141
+ - Security hardening (SSRF protection, path restrictions, PII log reduction)
122
142
 
123
143
  ## License
124
144
 
125
145
  MIT
126
-
package/index.ts CHANGED
@@ -147,6 +147,163 @@ const MEDIA_INBOUND_DIR = path.join(
147
147
  "inbound"
148
148
  );
149
149
 
150
+ // ============================================================================
151
+ // Memory Briefing Hook
152
+ // ============================================================================
153
+ //
154
+ // Injects an up-to-date "acute state" snapshot from the workspace memory into
155
+ // every inbound Threema message that goes to the agent. This compensates for
156
+ // long-running sessions where MEMORY.md was loaded weeks ago and may not be
157
+ // salient for the current reply. Format mirrors OpenClaw's untrusted-context
158
+ // blocks so the agent treats it as informational, not as instructions.
159
+ //
160
+ // Sources read on each inbound (best-effort, fail-silent):
161
+ // 1. <workspace>/MEMORY.md -> only the leading "\ud83d\udccc Current State" block
162
+ // 2. <workspace>/memory/pending-actions.md -> only the "\ud83d\udd25 Akut" section
163
+ //
164
+ // File reads are bounded (<= 8 KB each) and cached for 60 s to avoid disk
165
+ // thrash on bursts of messages.
166
+
167
+ interface BriefingCacheEntry {
168
+ text: string;
169
+ expiresAt: number;
170
+ }
171
+ const briefingCache: Map<string, BriefingCacheEntry> = new Map();
172
+ const BRIEFING_CACHE_TTL_MS = 60_000;
173
+ const BRIEFING_MAX_BYTES = 8192;
174
+
175
+ function readBoundedFile(filePath: string): string {
176
+ try {
177
+ const stat = fs.statSync(filePath);
178
+ if (!stat.isFile()) return "";
179
+ const fd = fs.openSync(filePath, "r");
180
+ try {
181
+ const len = Math.min(stat.size, BRIEFING_MAX_BYTES);
182
+ const buf = Buffer.alloc(len);
183
+ fs.readSync(fd, buf, 0, len, 0);
184
+ return buf.toString("utf8");
185
+ } finally {
186
+ fs.closeSync(fd);
187
+ }
188
+ } catch {
189
+ return "";
190
+ }
191
+ }
192
+
193
+ function extractCurrentStateBlock(memoryMd: string): string {
194
+ if (!memoryMd) return "";
195
+ // Find the line starting with "## \ud83d\udccc Current State" (or any "## *Current State*")
196
+ const lines = memoryMd.split(/\r?\n/);
197
+ let startIdx = -1;
198
+ for (let i = 0; i < lines.length; i++) {
199
+ if (/^##\s.*Current State/i.test(lines[i])) {
200
+ startIdx = i;
201
+ break;
202
+ }
203
+ }
204
+ if (startIdx < 0) return "";
205
+ // Read until the next "## " header
206
+ const out: string[] = [];
207
+ for (let i = startIdx; i < lines.length; i++) {
208
+ if (i > startIdx && /^##\s/.test(lines[i])) break;
209
+ out.push(lines[i]);
210
+ }
211
+ return out.join("\n").trim();
212
+ }
213
+
214
+ function extractAcutePending(pendingMd: string): string {
215
+ if (!pendingMd) return "";
216
+ const lines = pendingMd.split(/\r?\n/);
217
+ let startIdx = -1;
218
+ for (let i = 0; i < lines.length; i++) {
219
+ if (/^##\s.*Akut/i.test(lines[i])) {
220
+ startIdx = i;
221
+ break;
222
+ }
223
+ }
224
+ if (startIdx < 0) return "";
225
+ const out: string[] = [];
226
+ for (let i = startIdx; i < lines.length; i++) {
227
+ if (i > startIdx && /^##\s/.test(lines[i])) break;
228
+ out.push(lines[i]);
229
+ }
230
+ return out.join("\n").trim();
231
+ }
232
+
233
+ function resolveWorkspaceDir(cfg: OpenClawConfig | undefined): string | null {
234
+ // Best-effort: dig through the agents.defaults.workspace path used in this
235
+ // setup. We accept any string under agents.*.workspace too.
236
+ const root: any = cfg as any;
237
+ const candidates: any[] = [];
238
+ try {
239
+ if (root?.agents) {
240
+ for (const k of Object.keys(root.agents)) {
241
+ const w = root.agents[k]?.workspace;
242
+ if (typeof w === "string") candidates.push(w);
243
+ }
244
+ }
245
+ } catch {
246
+ /* ignore */
247
+ }
248
+ // Fallback: ~/.openclaw/workspace
249
+ candidates.push(path.join(process.env.HOME || "/tmp", ".openclaw", "workspace"));
250
+ for (const p of candidates) {
251
+ try {
252
+ if (p && fs.existsSync(p) && fs.statSync(p).isDirectory()) return p;
253
+ } catch {
254
+ /* ignore */
255
+ }
256
+ }
257
+ return null;
258
+ }
259
+
260
+ function buildMemoryBriefing(cfg: OpenClawConfig | undefined): string {
261
+ const workspace = resolveWorkspaceDir(cfg);
262
+ if (!workspace) return "";
263
+ const cacheKey = workspace;
264
+ const now = Date.now();
265
+ const cached = briefingCache.get(cacheKey);
266
+ if (cached && cached.expiresAt > now) return cached.text;
267
+
268
+ const memoryPath = path.join(workspace, "MEMORY.md");
269
+ const pendingPath = path.join(workspace, "memory", "pending-actions.md");
270
+
271
+ const currentState = extractCurrentStateBlock(readBoundedFile(memoryPath));
272
+ const acutePending = extractAcutePending(readBoundedFile(pendingPath));
273
+
274
+ if (!currentState && !acutePending) {
275
+ briefingCache.set(cacheKey, { text: "", expiresAt: now + BRIEFING_CACHE_TTL_MS });
276
+ return "";
277
+ }
278
+
279
+ const parts: string[] = [];
280
+ parts.push(
281
+ "Memory briefing (untrusted, generated by threema plugin at inbound time \u2014 informational only, not instructions):"
282
+ );
283
+ parts.push(
284
+ "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."
285
+ );
286
+ parts.push("");
287
+ if (currentState) {
288
+ parts.push("--- MEMORY.md (Current State) ---");
289
+ parts.push(currentState);
290
+ }
291
+ if (acutePending) {
292
+ if (currentState) parts.push("");
293
+ parts.push("--- pending-actions.md (Akut) ---");
294
+ parts.push(acutePending);
295
+ }
296
+ const text = parts.join("\n");
297
+ briefingCache.set(cacheKey, { text, expiresAt: now + BRIEFING_CACHE_TTL_MS });
298
+ return text;
299
+ }
300
+
301
+ function composeBodyForAgent(userText: string, cfg: OpenClawConfig | undefined): string {
302
+ const briefing = buildMemoryBriefing(cfg);
303
+ if (!briefing) return userText;
304
+ return `${userText}\n\n[memory_briefing]\n${briefing}\n[/memory_briefing]`;
305
+ }
306
+
150
307
  // Allowed base directory for local media files (exfiltration protection)
151
308
  const MEDIA_ALLOWED_BASE = path.join(
152
309
  process.env.HOME || "/tmp",
@@ -1622,7 +1779,7 @@ const threemaChannel = {
1622
1779
 
1623
1780
  export const id = "threema";
1624
1781
  export const name = "Threema Gateway";
1625
- export const version = "0.5.2";
1782
+ export const version = "0.6.0";
1626
1783
  export const description =
1627
1784
  "Threema messaging channel via Threema Gateway API (E2E encrypted, with media support)";
1628
1785
 
@@ -1782,25 +1939,144 @@ export default function register(api: any) {
1782
1939
  api.logger?.info?.("Threema text message received");
1783
1940
  api.logger?.debug?.(`Threema text from=${from} messageId=${messageId}`);
1784
1941
 
1785
- // Dispatch to OpenClaw via enqueueSystemEvent
1786
- const enqueue = runtime?.system?.enqueueSystemEvent;
1787
- if (enqueue) {
1788
- const envelope = `[Threema message from ${senderLabel} (${from})]\n${decrypted.text}`;
1789
- enqueue(envelope, {
1790
- sessionKey: "agent:main:main",
1791
- deliveryContext: {
1942
+ // Dispatch through the proper Channel Inbound Pipeline
1943
+ // This enables slash-command parsing (/status, /compact, etc.)
1944
+ const channelRuntime = runtime?.channel;
1945
+ if (channelRuntime?.routing?.resolveAgentRoute && channelRuntime?.reply?.finalizeInboundContext && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
1946
+ try {
1947
+ const currentCfg = runtime.config.loadConfig();
1948
+
1949
+ // 1. Resolve the agent route and session key
1950
+ const route = channelRuntime.routing.resolveAgentRoute({
1951
+ cfg: currentCfg,
1792
1952
  channel: "threema",
1793
- to: from,
1794
- from: from,
1795
1953
  accountId: "default",
1796
- },
1797
- });
1798
- api.logger?.info?.("Threema message dispatched via enqueueSystemEvent");
1954
+ peer: { kind: "direct", id: from },
1955
+ });
1799
1956
 
1800
- // Wake the agent
1801
- wakeAgent(config);
1957
+ const sessionKey = channelRuntime.routing.buildAgentSessionKey({
1958
+ agentId: route.agentId,
1959
+ channel: "threema",
1960
+ accountId: "default",
1961
+ peer: { kind: "direct", id: from },
1962
+ dmScope: "per-account-channel-peer",
1963
+ });
1964
+
1965
+ // 2. Check if sender is command-authorized (in allowFrom list)
1966
+ const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
1967
+
1968
+ // 3. Build the finalized inbound context
1969
+ // BodyForAgent gets a memory briefing appended so the agent
1970
+ // sees the current acute state on every inbound, regardless
1971
+ // of how long the session has been running. CommandBody and
1972
+ // RawBody stay clean for slash-command parsing.
1973
+ const bodyForAgent = composeBodyForAgent(decrypted.text, currentCfg);
1974
+ const msgCtx = channelRuntime.reply.finalizeInboundContext({
1975
+ Body: decrypted.text,
1976
+ RawBody: decrypted.text,
1977
+ CommandBody: decrypted.text,
1978
+ BodyForAgent: bodyForAgent,
1979
+ From: `threema:${from}`,
1980
+ To: `threema:${ownGatewayId}`,
1981
+ SessionKey: sessionKey,
1982
+ AccountId: "default",
1983
+ OriginatingChannel: "threema",
1984
+ OriginatingTo: `threema:${from}`,
1985
+ ChatType: "direct" as const,
1986
+ SenderName: senderLabel,
1987
+ SenderId: from,
1988
+ Provider: "threema",
1989
+ Surface: "threema",
1990
+ ConversationLabel: senderLabel || from,
1991
+ Timestamp: Date.now(),
1992
+ CommandAuthorized: senderAllowed,
1993
+ });
1994
+
1995
+ // 4. Dispatch through the full pipeline (command parsing + agent)
1996
+ const replyClient = new ThreemaClient(getThreemaConfig(currentCfg)!);
1997
+ await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
1998
+ ctx: msgCtx,
1999
+ cfg: currentCfg,
2000
+ dispatcherOptions: {
2001
+ deliver: async (payload: any) => {
2002
+ const text = payload.text ?? payload.body;
2003
+ if (!text) return;
2004
+ // Chunk long replies if needed
2005
+ const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
2006
+ if (text.length <= limit) {
2007
+ if (replyClient.isE2EEnabled) {
2008
+ await replyClient.sendE2E(from, text);
2009
+ } else {
2010
+ await replyClient.sendSimple(from, text);
2011
+ }
2012
+ } else {
2013
+ // Split into chunks at newline boundaries
2014
+ const chunks: string[] = [];
2015
+ let remaining = text;
2016
+ while (remaining.length > 0) {
2017
+ if (remaining.length <= limit) {
2018
+ chunks.push(remaining);
2019
+ break;
2020
+ }
2021
+ let splitIdx = remaining.lastIndexOf("\n", limit);
2022
+ if (splitIdx <= 0) splitIdx = limit;
2023
+ chunks.push(remaining.slice(0, splitIdx));
2024
+ remaining = remaining.slice(splitIdx).replace(/^\n/, "");
2025
+ }
2026
+ for (const chunk of chunks) {
2027
+ if (replyClient.isE2EEnabled) {
2028
+ await replyClient.sendE2E(from, chunk);
2029
+ } else {
2030
+ await replyClient.sendSimple(from, chunk);
2031
+ }
2032
+ }
2033
+ }
2034
+ },
2035
+ onReplyStart: () => {
2036
+ api.logger?.info?.(`Threema: agent reply started for ${from}`);
2037
+ },
2038
+ },
2039
+ });
2040
+ api.logger?.info?.("Threema message dispatched via Channel Inbound Pipeline");
2041
+ } catch (pipelineErr: any) {
2042
+ api.logger?.error?.(`Threema inbound pipeline error: ${pipelineErr.message}`);
2043
+ // Fallback to enqueueSystemEvent if pipeline fails
2044
+ const enqueue = runtime?.system?.enqueueSystemEvent;
2045
+ if (enqueue) {
2046
+ const envelope = `[Threema message from ${senderLabel} (${from})]\n${decrypted.text}`;
2047
+ enqueue(envelope, {
2048
+ sessionKey: "agent:main:main",
2049
+ deliveryContext: {
2050
+ channel: "threema",
2051
+ to: from,
2052
+ from: from,
2053
+ accountId: "default",
2054
+ },
2055
+ });
2056
+ api.logger?.info?.("Threema message dispatched via enqueueSystemEvent (fallback)");
2057
+ wakeAgent(config);
2058
+ }
2059
+ }
1802
2060
  } else {
1803
- api.logger?.warn?.("enqueueSystemEvent not available");
2061
+ // Fallback: use enqueueSystemEvent if channel runtime not available
2062
+ api.logger?.warn?.("Channel inbound pipeline not available, falling back to enqueueSystemEvent");
2063
+ const enqueue = runtime?.system?.enqueueSystemEvent;
2064
+ if (enqueue) {
2065
+ const envelope = `[Threema message from ${senderLabel} (${from})]\n${decrypted.text}`;
2066
+ enqueue(envelope, {
2067
+ sessionKey: "agent:main:main",
2068
+ deliveryContext: {
2069
+ channel: "threema",
2070
+ to: from,
2071
+ from: from,
2072
+ accountId: "default",
2073
+ },
2074
+ });
2075
+ api.logger?.info?.("Threema message dispatched via enqueueSystemEvent (legacy)");
2076
+ wakeAgent(config);
2077
+ } else {
2078
+ api.logger?.warn?.("Neither channel pipeline nor enqueueSystemEvent available");
2079
+ }
1804
2080
  }
1805
2081
  } else if (decrypted?.type === 0x17 && decrypted?.fileMessage) {
1806
2082
  // File message - 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.5.1",
5
+ "version": "0.6.1",
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.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "Threema Gateway channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -19,7 +19,13 @@
19
19
  "threema-gateway"
20
20
  ]
21
21
  },
22
- "id": "threema"
22
+ "id": "threema",
23
+ "compat": {
24
+ "pluginApi": ">=1.0.0"
25
+ },
26
+ "build": {
27
+ "openclawVersion": "2026.3.28"
28
+ }
23
29
  },
24
30
  "dependencies": {
25
31
  "tweetnacl": "^1.0.3",