openclaw-threema 0.6.7 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 (2026-05-06)
4
+
5
+ ### Added
6
+ - **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.
7
+ - New module: `markdown-to-threema.ts` (with full unit-test suite, 23/23 passing).
8
+ - Conversions:
9
+ - `**bold**` / `__bold__` → `*bold*` (Threema bold)
10
+ - `~~strike~~` → `~strike~` (Threema strikethrough)
11
+ - Single-asterisk `*x*` and underscore `_x_` left untouched.
12
+ - `# / ## / …` headers → `*Header*` (bold).
13
+ - `- / * / +` lists → `•` (Unicode bullet).
14
+ - Numbered lists kept as-is (Threema renders them fine plain).
15
+ - Blockquotes (`>`) → `│` (vertical bar).
16
+ - Pipe-tables → bullet-list with bold headers (`• *Header:* value`).
17
+ - Inline code `` `x` `` → `"x"`. Fenced code blocks → plain content, fences stripped.
18
+ - Links `[text](url)` → `text — url` (or just `url` when text == url).
19
+ - Markdown images `![alt](url)` → `[Bild: alt] — url`.
20
+ - Horizontal rules → unicode line.
21
+ - 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.
22
+ - Runs idempotently — valid Threema-already markup passes through unchanged.
23
+
24
+ ### Notes
25
+ - 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.
26
+
3
27
  ## 0.6.7 (2026-05-04)
4
28
 
5
29
  ### Added
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import * as path from "path";
12
12
  import { spawnSync } from "child_process";
13
13
  import * as crypto from "crypto";
14
14
  import * as dns from "node:dns/promises";
15
+ import { markdownToThreema } from "./markdown-to-threema.js";
15
16
  // ============================================================================
16
17
  // Constants
17
18
  // ============================================================================
@@ -1254,12 +1255,14 @@ const threemaChannel = {
1254
1255
  }
1255
1256
  const client = new ThreemaClient(threemaCfg);
1256
1257
  const to = normalizeThreemaTarget(ctx.to);
1258
+ // Convert Markdown → Threema-Markup before sending.
1259
+ const formatted = markdownToThreema(ctx.text);
1257
1260
  let messageId;
1258
1261
  if (client.isE2EEnabled) {
1259
- messageId = await client.sendE2E(to, ctx.text);
1262
+ messageId = await client.sendE2E(to, formatted);
1260
1263
  }
1261
1264
  else {
1262
- messageId = await client.sendSimple(to, ctx.text);
1265
+ messageId = await client.sendSimple(to, formatted);
1263
1266
  }
1264
1267
  return {
1265
1268
  channel: "threema",
@@ -1848,13 +1851,13 @@ export default function register(api) {
1848
1851
  if (fs.existsSync(audioPath)) {
1849
1852
  const audioBuffer = fs.readFileSync(audioPath);
1850
1853
  const mimeType = payload.mediaMimeType ?? "audio/aac";
1851
- const caption = payload.text ?? payload.body ?? undefined;
1854
+ const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
1852
1855
  await replyClient.sendVoiceNote(from, audioBuffer, mimeType, caption);
1853
1856
  }
1854
1857
  else {
1855
1858
  api.logger?.warn?.(`Threema: audio file not found at ${audioPath}, falling back to text`);
1856
1859
  // Fallback to text if audio file not found
1857
- const text = payload.text ?? payload.body;
1860
+ const text = markdownToThreema(payload.text ?? payload.body);
1858
1861
  if (text) {
1859
1862
  await replyClient.sendE2E(from, text);
1860
1863
  }
@@ -1863,7 +1866,7 @@ export default function register(api) {
1863
1866
  else {
1864
1867
  // Voice notes only work in E2E mode; fallback to text in basic mode
1865
1868
  api.logger?.info?.(`Threema: voice notes require E2E mode, sending text instead`);
1866
- const text = payload.text ?? payload.body;
1869
+ const text = markdownToThreema(payload.text ?? payload.body);
1867
1870
  if (text) {
1868
1871
  await replyClient.sendSimple(from, text);
1869
1872
  }
@@ -1872,7 +1875,7 @@ export default function register(api) {
1872
1875
  catch (audioErr) {
1873
1876
  api.logger?.error?.(`Threema: error sending voice note: ${audioErr.message}`);
1874
1877
  // Fallback to text on error
1875
- const text = payload.text ?? payload.body;
1878
+ const text = markdownToThreema(payload.text ?? payload.body);
1876
1879
  if (text) {
1877
1880
  if (replyClient.isE2EEnabled) {
1878
1881
  await replyClient.sendE2E(from, text);
@@ -1885,7 +1888,7 @@ export default function register(api) {
1885
1888
  }
1886
1889
  else {
1887
1890
  // Send as text (existing logic)
1888
- const text = payload.text ?? payload.body;
1891
+ const text = markdownToThreema(payload.text ?? payload.body);
1889
1892
  if (!text)
1890
1893
  return;
1891
1894
  // Chunk long replies if needed
@@ -2072,13 +2075,13 @@ export default function register(api) {
2072
2075
  if (fs.existsSync(audioPath)) {
2073
2076
  const audioBuffer = fs.readFileSync(audioPath);
2074
2077
  const mimeType = payload.mediaMimeType ?? "audio/aac";
2075
- const caption = payload.text ?? payload.body ?? undefined;
2078
+ const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
2076
2079
  await replyClient.sendVoiceNote(from, audioBuffer, mimeType, caption);
2077
2080
  }
2078
2081
  else {
2079
2082
  api.logger?.warn?.(`Threema: audio file not found at ${audioPath}, falling back to text`);
2080
2083
  // Fallback to text if audio file not found
2081
- const text = payload.text ?? payload.body;
2084
+ const text = markdownToThreema(payload.text ?? payload.body);
2082
2085
  if (text) {
2083
2086
  await replyClient.sendE2E(from, text);
2084
2087
  }
@@ -2087,7 +2090,7 @@ export default function register(api) {
2087
2090
  else {
2088
2091
  // Voice notes only work in E2E mode; fallback to text in basic mode
2089
2092
  api.logger?.info?.(`Threema: voice notes require E2E mode, sending text instead`);
2090
- const text = payload.text ?? payload.body;
2093
+ const text = markdownToThreema(payload.text ?? payload.body);
2091
2094
  if (text) {
2092
2095
  await replyClient.sendSimple(from, text);
2093
2096
  }
@@ -2096,7 +2099,7 @@ export default function register(api) {
2096
2099
  catch (audioErr) {
2097
2100
  api.logger?.error?.(`Threema: error sending voice note: ${audioErr.message}`);
2098
2101
  // Fallback to text on error
2099
- const text = payload.text ?? payload.body;
2102
+ const text = markdownToThreema(payload.text ?? payload.body);
2100
2103
  if (text) {
2101
2104
  if (replyClient.isE2EEnabled) {
2102
2105
  await replyClient.sendE2E(from, text);
@@ -2109,7 +2112,7 @@ export default function register(api) {
2109
2112
  }
2110
2113
  else {
2111
2114
  // Send as text (existing logic)
2112
- const text = payload.text ?? payload.body;
2115
+ const text = markdownToThreema(payload.text ?? payload.body);
2113
2116
  if (!text)
2114
2117
  return;
2115
2118
  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
@@ -13,6 +13,7 @@ import * as path from "path";
13
13
  import { spawnSync } from "child_process";
14
14
  import * as crypto from "crypto";
15
15
  import * as dns from "node:dns/promises";
16
+ import { markdownToThreema } from "./markdown-to-threema.js";
16
17
 
17
18
  // ============================================================================
18
19
  // Types (matching OpenClaw's expected interfaces)
@@ -1627,11 +1628,14 @@ const threemaChannel = {
1627
1628
  const client = new ThreemaClient(threemaCfg);
1628
1629
  const to = normalizeThreemaTarget(ctx.to);
1629
1630
 
1631
+ // Convert Markdown → Threema-Markup before sending.
1632
+ const formatted = markdownToThreema(ctx.text);
1633
+
1630
1634
  let messageId: string;
1631
1635
  if (client.isE2EEnabled) {
1632
- messageId = await client.sendE2E(to, ctx.text);
1636
+ messageId = await client.sendE2E(to, formatted);
1633
1637
  } else {
1634
- messageId = await client.sendSimple(to, ctx.text);
1638
+ messageId = await client.sendSimple(to, formatted);
1635
1639
  }
1636
1640
 
1637
1641
  return {
@@ -2365,12 +2369,12 @@ export default function register(api: any) {
2365
2369
  if (fs.existsSync(audioPath)) {
2366
2370
  const audioBuffer = fs.readFileSync(audioPath);
2367
2371
  const mimeType = payload.mediaMimeType ?? "audio/aac";
2368
- const caption = payload.text ?? payload.body ?? undefined;
2372
+ const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
2369
2373
  await replyClient.sendVoiceNote(from, audioBuffer, mimeType, caption);
2370
2374
  } else {
2371
2375
  api.logger?.warn?.(`Threema: audio file not found at ${audioPath}, falling back to text`);
2372
2376
  // Fallback to text if audio file not found
2373
- const text = payload.text ?? payload.body;
2377
+ const text = markdownToThreema(payload.text ?? payload.body);
2374
2378
  if (text) {
2375
2379
  await replyClient.sendE2E(from, text);
2376
2380
  }
@@ -2378,7 +2382,7 @@ export default function register(api: any) {
2378
2382
  } else {
2379
2383
  // Voice notes only work in E2E mode; fallback to text in basic mode
2380
2384
  api.logger?.info?.(`Threema: voice notes require E2E mode, sending text instead`);
2381
- const text = payload.text ?? payload.body;
2385
+ const text = markdownToThreema(payload.text ?? payload.body);
2382
2386
  if (text) {
2383
2387
  await replyClient.sendSimple(from, text);
2384
2388
  }
@@ -2386,7 +2390,7 @@ export default function register(api: any) {
2386
2390
  } catch (audioErr: any) {
2387
2391
  api.logger?.error?.(`Threema: error sending voice note: ${audioErr.message}`);
2388
2392
  // Fallback to text on error
2389
- const text = payload.text ?? payload.body;
2393
+ const text = markdownToThreema(payload.text ?? payload.body);
2390
2394
  if (text) {
2391
2395
  if (replyClient.isE2EEnabled) {
2392
2396
  await replyClient.sendE2E(from, text);
@@ -2397,7 +2401,7 @@ export default function register(api: any) {
2397
2401
  }
2398
2402
  } else {
2399
2403
  // Send as text (existing logic)
2400
- const text = payload.text ?? payload.body;
2404
+ const text = markdownToThreema(payload.text ?? payload.body);
2401
2405
  if (!text) return;
2402
2406
  // Chunk long replies if needed
2403
2407
  const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
@@ -2593,12 +2597,12 @@ export default function register(api: any) {
2593
2597
  if (fs.existsSync(audioPath)) {
2594
2598
  const audioBuffer = fs.readFileSync(audioPath);
2595
2599
  const mimeType = payload.mediaMimeType ?? "audio/aac";
2596
- const caption = payload.text ?? payload.body ?? undefined;
2600
+ const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
2597
2601
  await replyClient.sendVoiceNote(from, audioBuffer, mimeType, caption);
2598
2602
  } else {
2599
2603
  api.logger?.warn?.(`Threema: audio file not found at ${audioPath}, falling back to text`);
2600
2604
  // Fallback to text if audio file not found
2601
- const text = payload.text ?? payload.body;
2605
+ const text = markdownToThreema(payload.text ?? payload.body);
2602
2606
  if (text) {
2603
2607
  await replyClient.sendE2E(from, text);
2604
2608
  }
@@ -2606,7 +2610,7 @@ export default function register(api: any) {
2606
2610
  } else {
2607
2611
  // Voice notes only work in E2E mode; fallback to text in basic mode
2608
2612
  api.logger?.info?.(`Threema: voice notes require E2E mode, sending text instead`);
2609
- const text = payload.text ?? payload.body;
2613
+ const text = markdownToThreema(payload.text ?? payload.body);
2610
2614
  if (text) {
2611
2615
  await replyClient.sendSimple(from, text);
2612
2616
  }
@@ -2614,7 +2618,7 @@ export default function register(api: any) {
2614
2618
  } catch (audioErr: any) {
2615
2619
  api.logger?.error?.(`Threema: error sending voice note: ${audioErr.message}`);
2616
2620
  // Fallback to text on error
2617
- const text = payload.text ?? payload.body;
2621
+ const text = markdownToThreema(payload.text ?? payload.body);
2618
2622
  if (text) {
2619
2623
  if (replyClient.isE2EEnabled) {
2620
2624
  await replyClient.sendE2E(from, text);
@@ -2625,7 +2629,7 @@ export default function register(api: any) {
2625
2629
  }
2626
2630
  } else {
2627
2631
  // Send as text (existing logic)
2628
- const text = payload.text ?? payload.body;
2632
+ const text = markdownToThreema(payload.text ?? payload.body);
2629
2633
  if (!text) return;
2630
2634
  const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
2631
2635
  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.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.6.7",
3
+ "version": "0.7.0",
4
4
  "description": "Threema Gateway channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",