openclaw-threema 0.5.1 → 0.6.0

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
@@ -1109,6 +1109,28 @@ function getMimeFromPath(filePath: string): string {
1109
1109
  // Channel Plugin Definition
1110
1110
  // ============================================================================
1111
1111
 
1112
+ // Shared status updater — allows the webhook handler (registered outside the
1113
+ // channel adapter) to update the channel's health-monitor status on inbound events.
1114
+ // startAccount populates this; the webhook handler calls updateActivity().
1115
+ const channelStatus = {
1116
+ _getStatus: null as (() => ChannelAccountSnapshot) | null,
1117
+ _setStatus: null as ((s: ChannelAccountSnapshot) => void) | null,
1118
+ bind(getStatus: () => ChannelAccountSnapshot, setStatus: (s: ChannelAccountSnapshot) => void) {
1119
+ this._getStatus = getStatus;
1120
+ this._setStatus = setStatus;
1121
+ },
1122
+ updateActivity() {
1123
+ if (this._getStatus && this._setStatus) {
1124
+ const now = Date.now();
1125
+ this._setStatus({
1126
+ ...this._getStatus(),
1127
+ lastEventAt: now,
1128
+ lastInboundAt: now,
1129
+ });
1130
+ }
1131
+ },
1132
+ };
1133
+
1112
1134
  const threemaChannel = {
1113
1135
  id: "threema" as const,
1114
1136
 
@@ -1549,18 +1571,34 @@ const threemaChannel = {
1549
1571
  log?.info?.(`E2E public key: ${client.ownPublicKey}`);
1550
1572
  }
1551
1573
 
1574
+ // Bind shared status so the webhook handler can report activity
1575
+ channelStatus.bind(getStatus, setStatus);
1576
+
1552
1577
  setStatus({
1553
1578
  ...getStatus(),
1554
1579
  running: true,
1555
1580
  connected: true,
1556
1581
  lastConnectedAt: Date.now(),
1582
+ lastEventAt: Date.now(),
1557
1583
  });
1558
1584
 
1585
+ // Periodic health heartbeat — update lastEventAt every 15 min so the
1586
+ // health-monitor doesn't think we're stuck (webhook channels are passive).
1587
+ const heartbeatInterval = setInterval(() => {
1588
+ setStatus({
1589
+ ...getStatus(),
1590
+ lastEventAt: Date.now(),
1591
+ });
1592
+ }, 15 * 60 * 1000);
1593
+
1559
1594
  // Keep the promise alive until abortSignal fires — resolving immediately
1560
1595
  // causes the gateway to treat the channel as "exited" and restart it.
1561
1596
  await new Promise<void>((resolve) => {
1562
1597
  if (abortSignal.aborted) return resolve();
1563
- abortSignal.addEventListener("abort", () => resolve(), { once: true });
1598
+ abortSignal.addEventListener("abort", () => {
1599
+ clearInterval(heartbeatInterval);
1600
+ resolve();
1601
+ }, { once: true });
1564
1602
  });
1565
1603
 
1566
1604
  log?.info?.("Threema Gateway stopped via abort signal");
@@ -1584,7 +1622,7 @@ const threemaChannel = {
1584
1622
 
1585
1623
  export const id = "threema";
1586
1624
  export const name = "Threema Gateway";
1587
- export const version = "0.5.0";
1625
+ export const version = "0.6.0";
1588
1626
  export const description =
1589
1627
  "Threema messaging channel via Threema Gateway API (E2E encrypted, with media support)";
1590
1628
 
@@ -1594,6 +1632,12 @@ export default function register(api: any) {
1594
1632
  const threemaCfg = getThreemaConfig(config);
1595
1633
  const runtime = api.runtime;
1596
1634
 
1635
+ // Store runtime reference for wakeAgent() to use requestHeartbeatNow
1636
+ _runtimeApi = runtime;
1637
+
1638
+ // Debug: check if requestHeartbeatNow is available
1639
+ api.logger?.info?.(`Threema wake API: runtime.system.requestHeartbeatNow=${typeof runtime?.system?.requestHeartbeatNow}`);
1640
+
1597
1641
  // Register the channel plugin
1598
1642
  api.registerChannel({ plugin: threemaChannel });
1599
1643
  api.logger?.info?.("Threema channel plugin registered");
@@ -1606,17 +1650,16 @@ export default function register(api: any) {
1606
1650
  const dmPolicy = threemaCfg.dmPolicy ?? "allowlist";
1607
1651
  const allowFrom = threemaCfg.allowFrom ?? [];
1608
1652
 
1609
- // Use registerHttpHandler (not registerHttpRoute) so the webhook is NOT
1610
- // behind gateway auth Threema's API needs to POST without our token.
1611
- // The webhook has its own MAC-based authentication.
1612
- api.registerHttpHandler?.(async (req: any, res: any): Promise<boolean> => {
1613
- const url = new URL(req.url ?? "/", "http://localhost");
1614
- if (url.pathname !== webhookPath) return false;
1615
-
1653
+ // Webhook handler Threema's API POSTs without our gateway token,
1654
+ // so we need an unauthenticated route. The webhook has its own MAC-based auth.
1655
+ //
1656
+ // Forward-compatible: use registerHttpRoute with auth:"none" (2026.3.2+),
1657
+ // fall back to registerHttpHandler for 2026.3.1.
1658
+ const webhookHandler = async (req: any, res: any): Promise<void> => {
1616
1659
  if (req.method !== "POST") {
1617
1660
  res.writeHead(405, { "Content-Type": "text/plain" });
1618
1661
  res.end("Method Not Allowed");
1619
- return true;
1662
+ return;
1620
1663
  }
1621
1664
 
1622
1665
  try {
@@ -1669,11 +1712,13 @@ export default function register(api: any) {
1669
1712
  return;
1670
1713
  }
1671
1714
 
1672
- // 4. Validate date (not older than 10 minutes)
1715
+ // 4. Validate date (not older than 60 minutes)
1716
+ // Threema retries delivery on webhook errors, so messages can arrive late.
1717
+ // 60 min is generous enough for retries while still rejecting stale replays.
1673
1718
  if (date) {
1674
1719
  const msgTimestamp = parseInt(date, 10) * 1000; // Convert to ms
1675
1720
  const now = Date.now();
1676
- const maxAge = 10 * 60 * 1000; // 10 minutes in ms
1721
+ const maxAge = 60 * 60 * 1000; // 60 minutes in ms
1677
1722
  if (now - msgTimestamp > maxAge) {
1678
1723
  api.logger?.warn?.(`Threema webhook: message too old (date=${date})`);
1679
1724
  res.writeHead(400, { "Content-Type": "text/plain" });
@@ -1719,6 +1764,9 @@ export default function register(api: any) {
1719
1764
 
1720
1765
  // === PROCESSING PHASE ===
1721
1766
 
1767
+ // Report activity to health-monitor (prevents "stuck" restarts)
1768
+ channelStatus.updateActivity();
1769
+
1722
1770
  // Decrypt the message
1723
1771
  const senderPubKey = await client.getPublicKey(from);
1724
1772
  const decrypted = client.decryptMessage(
@@ -1734,25 +1782,138 @@ export default function register(api: any) {
1734
1782
  api.logger?.info?.("Threema text message received");
1735
1783
  api.logger?.debug?.(`Threema text from=${from} messageId=${messageId}`);
1736
1784
 
1737
- // Dispatch to OpenClaw via enqueueSystemEvent
1738
- const enqueue = runtime?.system?.enqueueSystemEvent;
1739
- if (enqueue) {
1740
- const envelope = `[Threema message from ${senderLabel} (${from})]\n${decrypted.text}`;
1741
- enqueue(envelope, {
1742
- sessionKey: "agent:main:main",
1743
- deliveryContext: {
1785
+ // Dispatch through the proper Channel Inbound Pipeline
1786
+ // This enables slash-command parsing (/status, /compact, etc.)
1787
+ const channelRuntime = runtime?.channel;
1788
+ if (channelRuntime?.routing?.resolveAgentRoute && channelRuntime?.reply?.finalizeInboundContext && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
1789
+ try {
1790
+ const currentCfg = runtime.config.loadConfig();
1791
+
1792
+ // 1. Resolve the agent route and session key
1793
+ const route = channelRuntime.routing.resolveAgentRoute({
1794
+ cfg: currentCfg,
1744
1795
  channel: "threema",
1745
- to: from,
1746
- from: from,
1747
1796
  accountId: "default",
1748
- },
1749
- });
1750
- api.logger?.info?.("Threema message dispatched via enqueueSystemEvent");
1797
+ peer: { kind: "direct", id: from },
1798
+ });
1751
1799
 
1752
- // Wake the agent
1753
- wakeAgent(config);
1800
+ const sessionKey = channelRuntime.routing.buildAgentSessionKey({
1801
+ agentId: route.agentId,
1802
+ channel: "threema",
1803
+ accountId: "default",
1804
+ peer: { kind: "direct", id: from },
1805
+ dmScope: "per-account-channel-peer",
1806
+ });
1807
+
1808
+ // 2. Check if sender is command-authorized (in allowFrom list)
1809
+ const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
1810
+
1811
+ // 3. Build the finalized inbound context
1812
+ const msgCtx = channelRuntime.reply.finalizeInboundContext({
1813
+ Body: decrypted.text,
1814
+ RawBody: decrypted.text,
1815
+ CommandBody: decrypted.text,
1816
+ From: `threema:${from}`,
1817
+ To: `threema:${ownGatewayId}`,
1818
+ SessionKey: sessionKey,
1819
+ AccountId: "default",
1820
+ OriginatingChannel: "threema",
1821
+ OriginatingTo: `threema:${from}`,
1822
+ ChatType: "direct" as const,
1823
+ SenderName: senderLabel,
1824
+ SenderId: from,
1825
+ Provider: "threema",
1826
+ Surface: "threema",
1827
+ ConversationLabel: senderLabel || from,
1828
+ Timestamp: Date.now(),
1829
+ CommandAuthorized: senderAllowed,
1830
+ });
1831
+
1832
+ // 4. Dispatch through the full pipeline (command parsing + agent)
1833
+ const replyClient = new ThreemaClient(getThreemaConfig(currentCfg)!);
1834
+ await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
1835
+ ctx: msgCtx,
1836
+ cfg: currentCfg,
1837
+ dispatcherOptions: {
1838
+ deliver: async (payload: any) => {
1839
+ const text = payload.text ?? payload.body;
1840
+ if (!text) return;
1841
+ // Chunk long replies if needed
1842
+ const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
1843
+ if (text.length <= limit) {
1844
+ if (replyClient.isE2EEnabled) {
1845
+ await replyClient.sendE2E(from, text);
1846
+ } else {
1847
+ await replyClient.sendSimple(from, text);
1848
+ }
1849
+ } else {
1850
+ // Split into chunks at newline boundaries
1851
+ const chunks: string[] = [];
1852
+ let remaining = text;
1853
+ while (remaining.length > 0) {
1854
+ if (remaining.length <= limit) {
1855
+ chunks.push(remaining);
1856
+ break;
1857
+ }
1858
+ let splitIdx = remaining.lastIndexOf("\n", limit);
1859
+ if (splitIdx <= 0) splitIdx = limit;
1860
+ chunks.push(remaining.slice(0, splitIdx));
1861
+ remaining = remaining.slice(splitIdx).replace(/^\n/, "");
1862
+ }
1863
+ for (const chunk of chunks) {
1864
+ if (replyClient.isE2EEnabled) {
1865
+ await replyClient.sendE2E(from, chunk);
1866
+ } else {
1867
+ await replyClient.sendSimple(from, chunk);
1868
+ }
1869
+ }
1870
+ }
1871
+ },
1872
+ onReplyStart: () => {
1873
+ api.logger?.info?.(`Threema: agent reply started for ${from}`);
1874
+ },
1875
+ },
1876
+ });
1877
+ api.logger?.info?.("Threema message dispatched via Channel Inbound Pipeline");
1878
+ } catch (pipelineErr: any) {
1879
+ api.logger?.error?.(`Threema inbound pipeline error: ${pipelineErr.message}`);
1880
+ // Fallback to enqueueSystemEvent if pipeline fails
1881
+ const enqueue = runtime?.system?.enqueueSystemEvent;
1882
+ if (enqueue) {
1883
+ const envelope = `[Threema message from ${senderLabel} (${from})]\n${decrypted.text}`;
1884
+ enqueue(envelope, {
1885
+ sessionKey: "agent:main:main",
1886
+ deliveryContext: {
1887
+ channel: "threema",
1888
+ to: from,
1889
+ from: from,
1890
+ accountId: "default",
1891
+ },
1892
+ });
1893
+ api.logger?.info?.("Threema message dispatched via enqueueSystemEvent (fallback)");
1894
+ wakeAgent(config);
1895
+ }
1896
+ }
1754
1897
  } else {
1755
- api.logger?.warn?.("enqueueSystemEvent not available");
1898
+ // Fallback: use enqueueSystemEvent if channel runtime not available
1899
+ api.logger?.warn?.("Channel inbound pipeline not available, falling back to enqueueSystemEvent");
1900
+ const enqueue = runtime?.system?.enqueueSystemEvent;
1901
+ if (enqueue) {
1902
+ const envelope = `[Threema message from ${senderLabel} (${from})]\n${decrypted.text}`;
1903
+ enqueue(envelope, {
1904
+ sessionKey: "agent:main:main",
1905
+ deliveryContext: {
1906
+ channel: "threema",
1907
+ to: from,
1908
+ from: from,
1909
+ accountId: "default",
1910
+ },
1911
+ });
1912
+ api.logger?.info?.("Threema message dispatched via enqueueSystemEvent (legacy)");
1913
+ wakeAgent(config);
1914
+ } else {
1915
+ api.logger?.warn?.("Neither channel pipeline nor enqueueSystemEvent available");
1916
+ }
1756
1917
  }
1757
1918
  } else if (decrypted?.type === 0x17 && decrypted?.fileMessage) {
1758
1919
  // File message - PII only on debug level
@@ -1834,9 +1995,37 @@ export default function register(api: any) {
1834
1995
  res.end("Internal Server Error");
1835
1996
  }
1836
1997
  }
1998
+ return;
1999
+ };
2000
+
2001
+ // Register the webhook — Threema's callback server POSTs without our
2002
+ // gateway auth token, so we need an unauthenticated route.
2003
+ //
2004
+ // Strategy: prefer registerHttpHandler (available in 2026.3.1, removed in 2026.3.2)
2005
+ // because it bypasses gateway auth. Fall back to registerHttpRoute with auth:"none"
2006
+ // (supported from 2026.3.2+). The order matters: registerHttpRoute in 2026.3.1
2007
+ // silently ignores auth:"none" and applies gateway auth, breaking external webhooks.
2008
+ if (api.registerHttpHandler) {
2009
+ // 2026.3.1: registerHttpHandler with manual path matching (no gateway auth)
2010
+ api.registerHttpHandler(async (req: any, res: any): Promise<boolean> => {
2011
+ const url = new URL(req.url ?? "/", "http://localhost");
2012
+ if (url.pathname !== webhookPath) return false;
2013
+ await webhookHandler(req, res);
1837
2014
  return true;
1838
- });
1839
- api.logger?.info?.(`Threema webhook registered at ${webhookPath}`);
2015
+ });
2016
+ api.logger?.info?.(`Threema webhook registered via registerHttpHandler at ${webhookPath}`);
2017
+ } else if (api.registerHttpRoute) {
2018
+ // 2026.3.2+: registerHttpHandler removed, use registerHttpRoute with auth:"plugin"
2019
+ // auth:"plugin" = no gateway auth, plugin handles its own auth (MAC verification)
2020
+ api.registerHttpRoute({
2021
+ path: webhookPath,
2022
+ auth: "plugin",
2023
+ handler: webhookHandler,
2024
+ });
2025
+ api.logger?.info?.(`Threema webhook registered via registerHttpRoute (auth:plugin) at ${webhookPath}`);
2026
+ } else {
2027
+ api.logger?.error?.("No HTTP registration method available — Threema webhook NOT registered!");
2028
+ }
1840
2029
  }
1841
2030
 
1842
2031
  // Register CLI commands
@@ -1962,10 +2151,21 @@ export default function register(api: any) {
1962
2151
  /**
1963
2152
  * Wake the agent to process new messages
1964
2153
  */
2154
+ // Shared reference to runtime API — set during register(), used by wakeAgent()
2155
+ let _runtimeApi: any = null;
2156
+
1965
2157
  function wakeAgent(config: any) {
2158
+ // Prefer the new requestHeartbeatNow API (2026.3.2+) — direct, no HTTP roundtrip
2159
+ if (_runtimeApi?.system?.requestHeartbeatNow) {
2160
+ try {
2161
+ _runtimeApi.system.requestHeartbeatNow({ sessionKey: "agent:main:main" });
2162
+ return;
2163
+ } catch {}
2164
+ }
2165
+
2166
+ // Fallback: HTTP wake call for older versions
1966
2167
  const gatewayPort = config?.gateway?.port || 18789;
1967
2168
 
1968
- // Try ENV first, fallback to config file (security: ENV is preferred)
1969
2169
  let hooksToken: string | undefined = process.env.OPENCLAW_HOOKS_TOKEN;
1970
2170
 
1971
2171
  if (!hooksToken) {
@@ -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.0",
5
+ "version": "0.6.0",
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.1",
3
+ "version": "0.6.0",
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",