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 +24 -0
- package/dist/index.js +15 -12
- package/dist/markdown-to-threema.js +235 -0
- package/index.ts +16 -12
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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 `` → `[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,
|
|
1262
|
+
messageId = await client.sendE2E(to, formatted);
|
|
1260
1263
|
}
|
|
1261
1264
|
else {
|
|
1262
|
-
messageId = await client.sendSimple(to,
|
|
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
|
|
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
|
|
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
|
+
* -  → [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:  → [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,
|
|
1636
|
+
messageId = await client.sendE2E(to, formatted);
|
|
1633
1637
|
} else {
|
|
1634
|
-
messageId = await client.sendSimple(to,
|
|
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
|
|
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
|
|
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) {
|
package/openclaw.plugin.json
CHANGED