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 +32 -0
- package/dist/index.js +17 -13
- package/dist/markdown-to-threema.js +235 -0
- package/index.ts +18 -13
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
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 `` → `[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
|
|
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,
|
|
1263
|
+
messageId = await client.sendE2E(to, formatted);
|
|
1260
1264
|
}
|
|
1261
1265
|
else {
|
|
1262
|
-
messageId = await client.sendSimple(to,
|
|
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
|
|
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
|
|
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
|
+
* -  → [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
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import nacl from "tweetnacl";
|
|
10
|
-
import
|
|
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,
|
|
1637
|
+
messageId = await client.sendE2E(to, formatted);
|
|
1633
1638
|
} else {
|
|
1634
|
-
messageId = await client.sendSimple(to,
|
|
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
|
|
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
|
|
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) {
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-threema",
|
|
3
|
-
"version": "0.
|
|
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
|
+
}
|