openclaw-threema 0.6.7 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.1 (2026-05-09)
4
+
5
+ ### Fixed
6
+ - **`tweetnacl-util` named-import broke under OpenClaw 2026.5.7's stricter ESM loader.** OC 2026.5.7 no longer accepts named-imports against CommonJS modules that use `module.exports = X`. The plugin failed to load with `SyntaxError: The requested module 'tweetnacl-util' does not provide an export named 'decodeUTF8'`.
7
+ - Fix: swapped `import { decodeUTF8 } from "tweetnacl-util"` for `import naclUtil from "tweetnacl-util"; const { decodeUTF8 } = naclUtil;` — same shape as how we already import `tweetnacl` itself.
8
+ - Mirror of the in-place hotfix that was applied to `dist/index.js` on 2026-05-08; this release pulls it back into source so it survives a fresh `npm install`.
9
+ - No behavior change. Drop-in upgrade from 0.7.0.
10
+
11
+ ## 0.7.0 (2026-05-06)
12
+
13
+ ### Added
14
+ - **Markdown → Threema-Markup conversion** for all outbound text. Threema natively supports `*bold*`, `_italic_`, and `~strikethrough~` since 2024 — but does NOT understand standard Markdown (`**bold**`, `# headers`, `- lists`, `[links](url)`, code fences, tables). The plugin now transparently converts Markdown into Threema-compatible markup before sending, so agent replies look correct in the Threema client without any prompt changes.
15
+ - New module: `markdown-to-threema.ts` (with full unit-test suite, 23/23 passing).
16
+ - Conversions:
17
+ - `**bold**` / `__bold__` → `*bold*` (Threema bold)
18
+ - `~~strike~~` → `~strike~` (Threema strikethrough)
19
+ - Single-asterisk `*x*` and underscore `_x_` left untouched.
20
+ - `# / ## / …` headers → `*Header*` (bold).
21
+ - `- / * / +` lists → `•` (Unicode bullet).
22
+ - Numbered lists kept as-is (Threema renders them fine plain).
23
+ - Blockquotes (`>`) → `│` (vertical bar).
24
+ - Pipe-tables → bullet-list with bold headers (`• *Header:* value`).
25
+ - Inline code `` `x` `` → `"x"`. Fenced code blocks → plain content, fences stripped.
26
+ - Links `[text](url)` → `text — url` (or just `url` when text == url).
27
+ - Markdown images `![alt](url)` → `[Bild: alt] — url`.
28
+ - Horizontal rules → unicode line.
29
+ - Hooked into all outbound paths: `outbound.sendText` adapter (cron / message-tool delivery), inbound text-reply callbacks (multi-chunk, voice fallback, regular DM replies), and inbound file-reply caption handling.
30
+ - Runs idempotently — valid Threema-already markup passes through unchanged.
31
+
32
+ ### Notes
33
+ - Behaviour change: previously, agent replies containing Markdown looked unrendered in Threema (literal `**`, `##`, `|`). Now they render with Threema’s native bold/italic/strikethrough wherever possible. Plain text and existing Threema markup are unaffected.
34
+
3
35
  ## 0.6.7 (2026-05-04)
4
36
 
5
37
  ### Added
package/dist/index.js CHANGED
@@ -6,12 +6,14 @@
6
6
  * Includes media (file message) support with audio transcription.
7
7
  */
8
8
  import nacl from "tweetnacl";
9
- import { decodeUTF8 } from "tweetnacl-util";
9
+ import naclUtil from "tweetnacl-util";
10
+ const { decodeUTF8 } = naclUtil;
10
11
  import * as fs from "fs";
11
12
  import * as path from "path";
12
13
  import { spawnSync } from "child_process";
13
14
  import * as crypto from "crypto";
14
15
  import * as dns from "node:dns/promises";
16
+ import { markdownToThreema } from "./markdown-to-threema.js";
15
17
  // ============================================================================
16
18
  // Constants
17
19
  // ============================================================================
@@ -1254,12 +1256,14 @@ const threemaChannel = {
1254
1256
  }
1255
1257
  const client = new ThreemaClient(threemaCfg);
1256
1258
  const to = normalizeThreemaTarget(ctx.to);
1259
+ // Convert Markdown → Threema-Markup before sending.
1260
+ const formatted = markdownToThreema(ctx.text);
1257
1261
  let messageId;
1258
1262
  if (client.isE2EEnabled) {
1259
- messageId = await client.sendE2E(to, ctx.text);
1263
+ messageId = await client.sendE2E(to, formatted);
1260
1264
  }
1261
1265
  else {
1262
- messageId = await client.sendSimple(to, ctx.text);
1266
+ messageId = await client.sendSimple(to, formatted);
1263
1267
  }
1264
1268
  return {
1265
1269
  channel: "threema",
@@ -1848,13 +1852,13 @@ export default function register(api) {
1848
1852
  if (fs.existsSync(audioPath)) {
1849
1853
  const audioBuffer = fs.readFileSync(audioPath);
1850
1854
  const mimeType = payload.mediaMimeType ?? "audio/aac";
1851
- const caption = payload.text ?? payload.body ?? undefined;
1855
+ const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
1852
1856
  await replyClient.sendVoiceNote(from, audioBuffer, mimeType, caption);
1853
1857
  }
1854
1858
  else {
1855
1859
  api.logger?.warn?.(`Threema: audio file not found at ${audioPath}, falling back to text`);
1856
1860
  // Fallback to text if audio file not found
1857
- const text = payload.text ?? payload.body;
1861
+ const text = markdownToThreema(payload.text ?? payload.body);
1858
1862
  if (text) {
1859
1863
  await replyClient.sendE2E(from, text);
1860
1864
  }
@@ -1863,7 +1867,7 @@ export default function register(api) {
1863
1867
  else {
1864
1868
  // Voice notes only work in E2E mode; fallback to text in basic mode
1865
1869
  api.logger?.info?.(`Threema: voice notes require E2E mode, sending text instead`);
1866
- const text = payload.text ?? payload.body;
1870
+ const text = markdownToThreema(payload.text ?? payload.body);
1867
1871
  if (text) {
1868
1872
  await replyClient.sendSimple(from, text);
1869
1873
  }
@@ -1872,7 +1876,7 @@ export default function register(api) {
1872
1876
  catch (audioErr) {
1873
1877
  api.logger?.error?.(`Threema: error sending voice note: ${audioErr.message}`);
1874
1878
  // Fallback to text on error
1875
- const text = payload.text ?? payload.body;
1879
+ const text = markdownToThreema(payload.text ?? payload.body);
1876
1880
  if (text) {
1877
1881
  if (replyClient.isE2EEnabled) {
1878
1882
  await replyClient.sendE2E(from, text);
@@ -1885,7 +1889,7 @@ export default function register(api) {
1885
1889
  }
1886
1890
  else {
1887
1891
  // Send as text (existing logic)
1888
- const text = payload.text ?? payload.body;
1892
+ const text = markdownToThreema(payload.text ?? payload.body);
1889
1893
  if (!text)
1890
1894
  return;
1891
1895
  // Chunk long replies if needed
@@ -2072,13 +2076,13 @@ export default function register(api) {
2072
2076
  if (fs.existsSync(audioPath)) {
2073
2077
  const audioBuffer = fs.readFileSync(audioPath);
2074
2078
  const mimeType = payload.mediaMimeType ?? "audio/aac";
2075
- const caption = payload.text ?? payload.body ?? undefined;
2079
+ const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
2076
2080
  await replyClient.sendVoiceNote(from, audioBuffer, mimeType, caption);
2077
2081
  }
2078
2082
  else {
2079
2083
  api.logger?.warn?.(`Threema: audio file not found at ${audioPath}, falling back to text`);
2080
2084
  // Fallback to text if audio file not found
2081
- const text = payload.text ?? payload.body;
2085
+ const text = markdownToThreema(payload.text ?? payload.body);
2082
2086
  if (text) {
2083
2087
  await replyClient.sendE2E(from, text);
2084
2088
  }
@@ -2087,7 +2091,7 @@ export default function register(api) {
2087
2091
  else {
2088
2092
  // Voice notes only work in E2E mode; fallback to text in basic mode
2089
2093
  api.logger?.info?.(`Threema: voice notes require E2E mode, sending text instead`);
2090
- const text = payload.text ?? payload.body;
2094
+ const text = markdownToThreema(payload.text ?? payload.body);
2091
2095
  if (text) {
2092
2096
  await replyClient.sendSimple(from, text);
2093
2097
  }
@@ -2096,7 +2100,7 @@ export default function register(api) {
2096
2100
  catch (audioErr) {
2097
2101
  api.logger?.error?.(`Threema: error sending voice note: ${audioErr.message}`);
2098
2102
  // Fallback to text on error
2099
- const text = payload.text ?? payload.body;
2103
+ const text = markdownToThreema(payload.text ?? payload.body);
2100
2104
  if (text) {
2101
2105
  if (replyClient.isE2EEnabled) {
2102
2106
  await replyClient.sendE2E(from, text);
@@ -2109,7 +2113,7 @@ export default function register(api) {
2109
2113
  }
2110
2114
  else {
2111
2115
  // Send as text (existing logic)
2112
- const text = payload.text ?? payload.body;
2116
+ const text = markdownToThreema(payload.text ?? payload.body);
2113
2117
  if (!text)
2114
2118
  return;
2115
2119
  const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
@@ -0,0 +1,235 @@
1
+ /**
2
+ * markdown-to-threema.ts
3
+ *
4
+ * Converts a Markdown string to Threema's native text-markup conventions.
5
+ *
6
+ * Threema-Markup (offizielle Threema-Inline-Formatierung, plattformübergreifend
7
+ * unterstützt seit 2024):
8
+ * *bold* — fett (genau ein Sternchen pro Seite)
9
+ * _italic_ — kursiv (genau ein Unterstrich pro Seite)
10
+ * ~strike~ — durchgestrichen (genau eine Tilde pro Seite)
11
+ *
12
+ * Markdown-Konstrukte, die Threema NICHT unterstützt, werden in
13
+ * sinnvolle Plain-Text-Repräsentationen überführt:
14
+ * - **bold** / __bold__ → *bold*
15
+ * - *italic* / _italic_ → _italic_ (so wie es ist; sicher-stellt nur, dass wir's nicht zerschießen)
16
+ * - ~~strike~~ → ~strike~
17
+ * - # / ## / ### Headers → *Header* (fett) + Leerzeile danach
18
+ * - - / * / + Liste → • (Unicode-Bullet)
19
+ * - 1. Nummerierte Liste → 1. (lassen wir; Threema rendert das eh als Plain)
20
+ * - > Blockquote → │ (Vertical-Bar, optisch wie Quote)
21
+ * - `inline code` → "inline code" (Anführungszeichen)
22
+ * - ```block code``` → Block bleibt erhalten, Backticks weg
23
+ * - [text](url) → text — url (oder nur url falls text==url)
24
+ * - ![alt](url) → [Bild: alt] — url (Markdown-Image)
25
+ * - | a | b | → a — b (Tabelle wird zu Plain-Listen)
26
+ *
27
+ * Das Ziel ist nicht 100 % treue Markdown-Erhaltung, sondern:
28
+ * 1. Was Threema kann, nutzen.
29
+ * 2. Was Threema nicht kann, optisch ordentlich zerlegen.
30
+ * 3. Inhalt erhält bleiben — keine Datenverluste.
31
+ *
32
+ * Heuristik-Reihenfolge wichtig:
33
+ * 1. Code-Blöcke (Fenced) zuerst herausziehen → escapen → später wieder einfügen.
34
+ * 2. Inline-Code (Backticks) auch escapen.
35
+ * 3. Tabellen → Bullet-List.
36
+ * 4. Bold/Italic/Strikethrough → Threema-Markup.
37
+ * 5. Headers → fett.
38
+ * 6. Listen → Bullets.
39
+ * 7. Blockquotes → Vertical-Bar.
40
+ * 8. Links / Images → Plain-Auflösung.
41
+ */
42
+ const PLACEHOLDER_PREFIX = "\u0001THREEMA_PH_";
43
+ const PLACEHOLDER_SUFFIX = "\u0001";
44
+ /**
45
+ * Convert Markdown → Threema text markup.
46
+ *
47
+ * Always returns a string. Empty input → empty output.
48
+ * The conversion is best-effort and idempotent for valid Threema-text input.
49
+ */
50
+ export function markdownToThreema(input) {
51
+ if (!input)
52
+ return "";
53
+ let text = input;
54
+ // ---- 1. Stash code blocks & inline code so we don't munge them ----
55
+ const stash = [];
56
+ const stashOne = (val) => {
57
+ const idx = stash.length;
58
+ stash.push(val);
59
+ return `${PLACEHOLDER_PREFIX}${idx}${PLACEHOLDER_SUFFIX}`;
60
+ };
61
+ // Fenced code blocks: ```lang\n...\n``` or ~~~...~~~
62
+ text = text.replace(/```([a-zA-Z0-9_+-]*)?\n([\s\S]*?)\n```/g, (_m, _lang, body) => stashOne(body.replace(/\s+$/g, "")));
63
+ text = text.replace(/~~~([a-zA-Z0-9_+-]*)?\n([\s\S]*?)\n~~~/g, (_m, _lang, body) => stashOne(body.replace(/\s+$/g, "")));
64
+ // Inline code: `code` (single backticks, no embedded backticks)
65
+ text = text.replace(/`([^`\n]+)`/g, (_m, body) => stashOne(`"${body}"`));
66
+ // ---- 2. Tables → bullet lists ----
67
+ // A Markdown table is detected by a header row + a separator row of dashes.
68
+ // We turn each data row into a bullet line "col1 — col2 — col3" and
69
+ // the header row into "*col1 — col2*" prefix.
70
+ text = convertTables(text);
71
+ // ---- 3. Block-level transforms (line by line) ----
72
+ const lines = text.split("\n");
73
+ const outLines = [];
74
+ for (let i = 0; i < lines.length; i += 1) {
75
+ let line = lines[i];
76
+ // Headers # / ## / ### → *Header*
77
+ const headerMatch = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
78
+ if (headerMatch) {
79
+ const headerText = headerMatch[2];
80
+ outLines.push(`*${stripInlineMarkers(headerText)}*`);
81
+ continue;
82
+ }
83
+ // Horizontal rule: ---, ___, ***
84
+ if (/^\s*([-*_])\1\1+\s*$/.test(line)) {
85
+ outLines.push("─────────────");
86
+ continue;
87
+ }
88
+ // Blockquote: > foo
89
+ const quoteMatch = /^(\s*)>\s?(.*)$/.exec(line);
90
+ if (quoteMatch) {
91
+ const [, indent, body] = quoteMatch;
92
+ outLines.push(`${indent}│ ${body}`);
93
+ continue;
94
+ }
95
+ // Unordered list: -, *, +
96
+ const ulMatch = /^(\s*)[-*+]\s+(.*)$/.exec(line);
97
+ if (ulMatch) {
98
+ const [, indent, body] = ulMatch;
99
+ outLines.push(`${indent}• ${body}`);
100
+ continue;
101
+ }
102
+ // Numbered list: 1. foo (keep as-is, Threema renders that fine plain)
103
+ // → no transform needed.
104
+ outLines.push(line);
105
+ }
106
+ text = outLines.join("\n");
107
+ // ---- 4. Inline replacements ----
108
+ // Markdown-Image: ![alt](url) → [Bild: alt] — url (or just url)
109
+ text = text.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_m, alt, url) => {
110
+ if (!alt.trim())
111
+ return url;
112
+ return `[Bild: ${alt}] — ${url}`;
113
+ });
114
+ // Link: [text](url) → text — url (or just url if text === url)
115
+ text = text.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_m, label, url) => {
116
+ const trimmed = label.trim();
117
+ if (!trimmed || trimmed === url)
118
+ return url;
119
+ return `${trimmed} — ${url}`;
120
+ });
121
+ // Bold: **text** or __text__ → *text* (Threema)
122
+ // Run this BEFORE italic so we don't accidentally chew **x** into *_x_*.
123
+ text = text.replace(/\*\*([^*\n][^*\n]*?)\*\*/g, "*$1*");
124
+ text = text.replace(/__([^_\n][^_\n]*?)__/g, "*$1*");
125
+ // Strikethrough: ~~text~~ → ~text~
126
+ text = text.replace(/~~([^~\n]+?)~~/g, "~$1~");
127
+ // Italic with *text*: Threema's *...* is BOLD, not italic. Markdown
128
+ // *italic* with single asterisks would render as bold in Threema.
129
+ // The safer move: convert single-asterisk *italic* to underscore _italic_.
130
+ // BUT: We just emitted *bold* above for **bold**. We must NOT touch
131
+ // existing *bold* output. Heuristic: only convert remaining *…* if the
132
+ // word inside contains no spaces around it being already-bold.
133
+ //
134
+ // Reality: distinguishing original *italic* (single asterisks) from
135
+ // converted *bold* (also single asterisks) post-hoc is impossible.
136
+ //
137
+ // Pragmatic decision: We do NOT touch single asterisks. Models in 2026
138
+ // overwhelmingly produce **bold** (double) and _italic_ (underscore),
139
+ // so this is a non-issue. If a model produces *italic*, it ends up as
140
+ // bold in Threema — minor cosmetic glitch, not a disaster.
141
+ // ---- 5. Restore code stash ----
142
+ text = text.replace(new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, "g"), (_m, idx) => stash[parseInt(idx, 10)] ?? _m);
143
+ // ---- 6. Tidy: collapse 3+ consecutive blank lines to 2 ----
144
+ text = text.replace(/\n{3,}/g, "\n\n");
145
+ return text;
146
+ }
147
+ /**
148
+ * Strip Markdown inline markers from header text.
149
+ * Headers in Markdown are usually "# **Bold Text**" — we want to land at
150
+ * "*Bold Text*" in Threema, not "**Bold Text**".
151
+ */
152
+ function stripInlineMarkers(s) {
153
+ let r = s;
154
+ r = r.replace(/\*\*(.+?)\*\*/g, "$1");
155
+ r = r.replace(/__(.+?)__/g, "$1");
156
+ r = r.replace(/~~(.+?)~~/g, "$1");
157
+ // single *…* and _…_ left as-is so headers can still be italic/bold inside
158
+ return r;
159
+ }
160
+ /**
161
+ * Convert Markdown pipe-tables into a flat bullet representation.
162
+ * Heuristic: We look for the classic | hdr | hdr | row, then a separator row
163
+ * of dashes |---|---|, then data rows.
164
+ */
165
+ function convertTables(text) {
166
+ const lines = text.split("\n");
167
+ const out = [];
168
+ let i = 0;
169
+ while (i < lines.length) {
170
+ const line = lines[i];
171
+ // Detect potential header row
172
+ if (looksLikeTableRow(line) && i + 1 < lines.length && looksLikeTableSeparator(lines[i + 1])) {
173
+ const headers = parseTableRow(line);
174
+ i += 2; // skip header + separator
175
+ const rows = [];
176
+ while (i < lines.length && looksLikeTableRow(lines[i])) {
177
+ rows.push(parseTableRow(lines[i]));
178
+ i += 1;
179
+ }
180
+ // Emit: header line as bold + each data row as a bullet block
181
+ if (headers.length > 0 && rows.length > 0) {
182
+ // Multi-line per row: each row gets its own block of "*hdr:* value" lines
183
+ for (const row of rows) {
184
+ for (let c = 0; c < headers.length; c += 1) {
185
+ const h = headers[c]?.trim();
186
+ const v = row[c]?.trim() ?? "";
187
+ if (!h)
188
+ continue;
189
+ // First column gets a bullet, rest indented
190
+ if (c === 0) {
191
+ out.push(`• *${h}:* ${v}`);
192
+ }
193
+ else {
194
+ out.push(` *${h}:* ${v}`);
195
+ }
196
+ }
197
+ out.push(""); // blank line between rows
198
+ }
199
+ // Drop the trailing blank line we added
200
+ if (out.length > 0 && out[out.length - 1] === "")
201
+ out.pop();
202
+ continue;
203
+ }
204
+ }
205
+ out.push(line);
206
+ i += 1;
207
+ }
208
+ return out.join("\n");
209
+ }
210
+ function looksLikeTableRow(line) {
211
+ if (!line)
212
+ return false;
213
+ const trimmed = line.trim();
214
+ if (!trimmed.startsWith("|") && !trimmed.includes("|"))
215
+ return false;
216
+ // Must contain at least 2 | characters to qualify
217
+ const pipeCount = (trimmed.match(/\|/g) ?? []).length;
218
+ return pipeCount >= 2;
219
+ }
220
+ function looksLikeTableSeparator(line) {
221
+ if (!line)
222
+ return false;
223
+ const trimmed = line.trim();
224
+ // | --- | --- | or | :--- | ---: |
225
+ return /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?$/.test(trimmed);
226
+ }
227
+ function parseTableRow(line) {
228
+ // Strip leading/trailing | and split on |
229
+ let s = line.trim();
230
+ if (s.startsWith("|"))
231
+ s = s.slice(1);
232
+ if (s.endsWith("|"))
233
+ s = s.slice(0, -1);
234
+ return s.split("|").map((cell) => cell.trim());
235
+ }
package/index.ts CHANGED
@@ -7,12 +7,14 @@
7
7
  */
8
8
 
9
9
  import nacl from "tweetnacl";
10
- import { decodeUTF8 } from "tweetnacl-util";
10
+ import naclUtil from "tweetnacl-util";
11
+ const { decodeUTF8 } = naclUtil;
11
12
  import * as fs from "fs";
12
13
  import * as path from "path";
13
14
  import { spawnSync } from "child_process";
14
15
  import * as crypto from "crypto";
15
16
  import * as dns from "node:dns/promises";
17
+ import { markdownToThreema } from "./markdown-to-threema.js";
16
18
 
17
19
  // ============================================================================
18
20
  // Types (matching OpenClaw's expected interfaces)
@@ -1627,11 +1629,14 @@ const threemaChannel = {
1627
1629
  const client = new ThreemaClient(threemaCfg);
1628
1630
  const to = normalizeThreemaTarget(ctx.to);
1629
1631
 
1632
+ // Convert Markdown → Threema-Markup before sending.
1633
+ const formatted = markdownToThreema(ctx.text);
1634
+
1630
1635
  let messageId: string;
1631
1636
  if (client.isE2EEnabled) {
1632
- messageId = await client.sendE2E(to, ctx.text);
1637
+ messageId = await client.sendE2E(to, formatted);
1633
1638
  } else {
1634
- messageId = await client.sendSimple(to, ctx.text);
1639
+ messageId = await client.sendSimple(to, formatted);
1635
1640
  }
1636
1641
 
1637
1642
  return {
@@ -2365,12 +2370,12 @@ export default function register(api: any) {
2365
2370
  if (fs.existsSync(audioPath)) {
2366
2371
  const audioBuffer = fs.readFileSync(audioPath);
2367
2372
  const mimeType = payload.mediaMimeType ?? "audio/aac";
2368
- const caption = payload.text ?? payload.body ?? undefined;
2373
+ const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
2369
2374
  await replyClient.sendVoiceNote(from, audioBuffer, mimeType, caption);
2370
2375
  } else {
2371
2376
  api.logger?.warn?.(`Threema: audio file not found at ${audioPath}, falling back to text`);
2372
2377
  // Fallback to text if audio file not found
2373
- const text = payload.text ?? payload.body;
2378
+ const text = markdownToThreema(payload.text ?? payload.body);
2374
2379
  if (text) {
2375
2380
  await replyClient.sendE2E(from, text);
2376
2381
  }
@@ -2378,7 +2383,7 @@ export default function register(api: any) {
2378
2383
  } else {
2379
2384
  // Voice notes only work in E2E mode; fallback to text in basic mode
2380
2385
  api.logger?.info?.(`Threema: voice notes require E2E mode, sending text instead`);
2381
- const text = payload.text ?? payload.body;
2386
+ const text = markdownToThreema(payload.text ?? payload.body);
2382
2387
  if (text) {
2383
2388
  await replyClient.sendSimple(from, text);
2384
2389
  }
@@ -2386,7 +2391,7 @@ export default function register(api: any) {
2386
2391
  } catch (audioErr: any) {
2387
2392
  api.logger?.error?.(`Threema: error sending voice note: ${audioErr.message}`);
2388
2393
  // Fallback to text on error
2389
- const text = payload.text ?? payload.body;
2394
+ const text = markdownToThreema(payload.text ?? payload.body);
2390
2395
  if (text) {
2391
2396
  if (replyClient.isE2EEnabled) {
2392
2397
  await replyClient.sendE2E(from, text);
@@ -2397,7 +2402,7 @@ export default function register(api: any) {
2397
2402
  }
2398
2403
  } else {
2399
2404
  // Send as text (existing logic)
2400
- const text = payload.text ?? payload.body;
2405
+ const text = markdownToThreema(payload.text ?? payload.body);
2401
2406
  if (!text) return;
2402
2407
  // Chunk long replies if needed
2403
2408
  const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
@@ -2593,12 +2598,12 @@ export default function register(api: any) {
2593
2598
  if (fs.existsSync(audioPath)) {
2594
2599
  const audioBuffer = fs.readFileSync(audioPath);
2595
2600
  const mimeType = payload.mediaMimeType ?? "audio/aac";
2596
- const caption = payload.text ?? payload.body ?? undefined;
2601
+ const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
2597
2602
  await replyClient.sendVoiceNote(from, audioBuffer, mimeType, caption);
2598
2603
  } else {
2599
2604
  api.logger?.warn?.(`Threema: audio file not found at ${audioPath}, falling back to text`);
2600
2605
  // Fallback to text if audio file not found
2601
- const text = payload.text ?? payload.body;
2606
+ const text = markdownToThreema(payload.text ?? payload.body);
2602
2607
  if (text) {
2603
2608
  await replyClient.sendE2E(from, text);
2604
2609
  }
@@ -2606,7 +2611,7 @@ export default function register(api: any) {
2606
2611
  } else {
2607
2612
  // Voice notes only work in E2E mode; fallback to text in basic mode
2608
2613
  api.logger?.info?.(`Threema: voice notes require E2E mode, sending text instead`);
2609
- const text = payload.text ?? payload.body;
2614
+ const text = markdownToThreema(payload.text ?? payload.body);
2610
2615
  if (text) {
2611
2616
  await replyClient.sendSimple(from, text);
2612
2617
  }
@@ -2614,7 +2619,7 @@ export default function register(api: any) {
2614
2619
  } catch (audioErr: any) {
2615
2620
  api.logger?.error?.(`Threema: error sending voice note: ${audioErr.message}`);
2616
2621
  // Fallback to text on error
2617
- const text = payload.text ?? payload.body;
2622
+ const text = markdownToThreema(payload.text ?? payload.body);
2618
2623
  if (text) {
2619
2624
  if (replyClient.isE2EEnabled) {
2620
2625
  await replyClient.sendE2E(from, text);
@@ -2625,7 +2630,7 @@ export default function register(api: any) {
2625
2630
  }
2626
2631
  } else {
2627
2632
  // Send as text (existing logic)
2628
- const text = payload.text ?? payload.body;
2633
+ const text = markdownToThreema(payload.text ?? payload.body);
2629
2634
  if (!text) return;
2630
2635
  const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
2631
2636
  if (text.length <= limit) {
@@ -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.7",
5
+ "version": "0.7.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.6.7",
3
+ "version": "0.7.1",
4
4
  "description": "Threema Gateway channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -67,4 +67,4 @@
67
67
  "type": "git",
68
68
  "url": "https://github.com/azrael-solution/openclaw-threema"
69
69
  }
70
- }
70
+ }