openclaw-threema 0.6.3 → 0.6.5
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 +25 -0
- package/dist/index.js +236 -54
- package/index.ts +245 -50
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.5 (2026-05-04)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Voice-Reply Function**: Threema plugin now supports sending voice notes (audio messages).
|
|
7
|
+
- New `sendVoiceNote(toId, audioBuffer, mimeType, caption)` method on ThreemaClient for E2E encrypted voice messages.
|
|
8
|
+
- When agent reply contains `audioAsVoice: true` with a `mediaUrl` (e.g., TTS or Whisper output), the plugin automatically sends it as a voice message instead of text.
|
|
9
|
+
- Audio detection works in both text-inbound and file-inbound reply pipelines.
|
|
10
|
+
- Fallback to text mode when audio file not found or when E2E mode is disabled (voice notes require E2E).
|
|
11
|
+
- Supports multiple audio MIME types: audio/aac, audio/mpeg, audio/wav, audio/ogg, audio/m4a, audio/webm.
|
|
12
|
+
- Error handling: logs errors and gracefully falls back to text delivery if voice send fails.
|
|
13
|
+
|
|
14
|
+
## 0.6.4 (2026-05-04)
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- v0.6.3 introduced a regression where file inbounds always fell back to
|
|
18
|
+
the legacy `enqueueSystemEvent` path. The new pipeline branch was
|
|
19
|
+
trying to call a non-existent `channelRuntime.reply.resolveDirectSession-
|
|
20
|
+
Key`. The text path doesn't use that helper at all; it uses
|
|
21
|
+
`channelRuntime.routing.resolveAgentRoute` + `buildAgentSessionKey`.
|
|
22
|
+
This release applies the same approach to the file path, so file
|
|
23
|
+
inbounds finally land in the live Threema DM session.
|
|
24
|
+
- Symptom: `Threema file inbound pipeline error: channelRuntime.reply.
|
|
25
|
+
resolveDirectSessionKey is not a function` followed by
|
|
26
|
+
`dispatched via enqueueSystemEvent (fallback)` for every file message.
|
|
27
|
+
|
|
3
28
|
## 0.6.3 (2026-05-04)
|
|
4
29
|
|
|
5
30
|
### Fixed
|
package/dist/index.js
CHANGED
|
@@ -327,6 +327,79 @@ class ThreemaClient {
|
|
|
327
327
|
const decrypted = nacl.secretbox.open(encryptedBlob, nonce, key);
|
|
328
328
|
return decrypted || null;
|
|
329
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Send a voice note message (audio file with voice message rendering type).
|
|
332
|
+
* Type 0x17 file message with j=1 (media rendering) and audio MIME type.
|
|
333
|
+
* Suitable for Whisper transcriptions or agent-generated TTS audio.
|
|
334
|
+
*/
|
|
335
|
+
async sendVoiceNote(to, audioBuffer, mimeType = "audio/aac", caption) {
|
|
336
|
+
if (!this.privateKey) {
|
|
337
|
+
throw new Error("E2E mode requires privateKey configuration");
|
|
338
|
+
}
|
|
339
|
+
const recipientPubKey = await this.getPublicKey(to);
|
|
340
|
+
// Generate random symmetric key for file encryption
|
|
341
|
+
const fileKey = nacl.randomBytes(32);
|
|
342
|
+
// Threema FILE_NONCE: 23 zero bytes + 0x01
|
|
343
|
+
const fileNonce = new Uint8Array(24);
|
|
344
|
+
fileNonce[23] = 0x01;
|
|
345
|
+
// Encrypt the audio with secretbox
|
|
346
|
+
const encryptedAudio = nacl.secretbox(new Uint8Array(audioBuffer), fileNonce, fileKey);
|
|
347
|
+
// Upload encrypted blob
|
|
348
|
+
const blobId = await this.uploadBlob(encryptedAudio);
|
|
349
|
+
// Create file message JSON for voice note
|
|
350
|
+
// j=1 marks it as media (voice message bubble in UI)
|
|
351
|
+
const fileMsg = {
|
|
352
|
+
b: blobId,
|
|
353
|
+
k: bytesToHex(fileKey),
|
|
354
|
+
m: mimeType,
|
|
355
|
+
n: `voice.${this.getMimeExtension(mimeType)}`,
|
|
356
|
+
s: audioBuffer.length,
|
|
357
|
+
j: 1, // 1 = render as media (voice message bubble)
|
|
358
|
+
i: 1, // deprecated but needed for older clients
|
|
359
|
+
};
|
|
360
|
+
if (caption) {
|
|
361
|
+
fileMsg.d = caption;
|
|
362
|
+
}
|
|
363
|
+
const fileMsgJson = JSON.stringify(fileMsg);
|
|
364
|
+
const fileMsgBytes = decodeUTF8(fileMsgJson);
|
|
365
|
+
// Create E2E payload (type 0x17 = file message)
|
|
366
|
+
const payload = buildE2EPayload(0x17, fileMsgBytes);
|
|
367
|
+
// Generate nonce and encrypt with NaCl box
|
|
368
|
+
const nonce = nacl.randomBytes(24);
|
|
369
|
+
const box = nacl.box(payload, nonce, recipientPubKey, this.privateKey);
|
|
370
|
+
const params = new URLSearchParams({
|
|
371
|
+
from: this.gatewayId,
|
|
372
|
+
to,
|
|
373
|
+
nonce: bytesToHex(nonce),
|
|
374
|
+
box: bytesToHex(box),
|
|
375
|
+
secret: this.secretKey,
|
|
376
|
+
});
|
|
377
|
+
const url = `${THREEMA_API_BASE}/send_e2e`;
|
|
378
|
+
const res = await fetch(url, {
|
|
379
|
+
method: "POST",
|
|
380
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
381
|
+
body: params.toString(),
|
|
382
|
+
});
|
|
383
|
+
if (!res.ok) {
|
|
384
|
+
// Don't log response body (may contain secrets)
|
|
385
|
+
throw new Error(`Threema E2E API error ${res.status}`);
|
|
386
|
+
}
|
|
387
|
+
return res.text();
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Get file extension for MIME type
|
|
391
|
+
*/
|
|
392
|
+
getMimeExtension(mimeType) {
|
|
393
|
+
const mimeMap = {
|
|
394
|
+
"audio/aac": "aac",
|
|
395
|
+
"audio/mpeg": "mp3",
|
|
396
|
+
"audio/wav": "wav",
|
|
397
|
+
"audio/ogg": "ogg",
|
|
398
|
+
"audio/m4a": "m4a",
|
|
399
|
+
"audio/webm": "webm",
|
|
400
|
+
};
|
|
401
|
+
return mimeMap[mimeType.toLowerCase()] || "m4a";
|
|
402
|
+
}
|
|
330
403
|
/**
|
|
331
404
|
* Send a text message (Basic mode - server-side encryption)
|
|
332
405
|
*/
|
|
@@ -1554,40 +1627,89 @@ export default function register(api) {
|
|
|
1554
1627
|
cfg: currentCfg,
|
|
1555
1628
|
dispatcherOptions: {
|
|
1556
1629
|
deliver: async (payload) => {
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1630
|
+
// Check if reply contains audio that should be sent as voice note
|
|
1631
|
+
const isAudioAsVoice = payload.audioAsVoice === true && payload.mediaUrl;
|
|
1632
|
+
if (isAudioAsVoice && payload.mediaUrl) {
|
|
1633
|
+
// Send as voice note
|
|
1634
|
+
try {
|
|
1635
|
+
if (replyClient.isE2EEnabled) {
|
|
1636
|
+
// Load audio from mediaUrl and send via sendVoiceNote
|
|
1637
|
+
const audioPath = payload.mediaUrl;
|
|
1638
|
+
if (fs.existsSync(audioPath)) {
|
|
1639
|
+
const audioBuffer = fs.readFileSync(audioPath);
|
|
1640
|
+
const mimeType = payload.mediaMimeType ?? "audio/aac";
|
|
1641
|
+
const caption = payload.text ?? payload.body ?? undefined;
|
|
1642
|
+
await replyClient.sendVoiceNote(from, audioBuffer, mimeType, caption);
|
|
1643
|
+
}
|
|
1644
|
+
else {
|
|
1645
|
+
api.logger?.warn?.(`Threema: audio file not found at ${audioPath}, falling back to text`);
|
|
1646
|
+
// Fallback to text if audio file not found
|
|
1647
|
+
const text = payload.text ?? payload.body;
|
|
1648
|
+
if (text) {
|
|
1649
|
+
await replyClient.sendE2E(from, text);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
else {
|
|
1654
|
+
// Voice notes only work in E2E mode; fallback to text in basic mode
|
|
1655
|
+
api.logger?.info?.(`Threema: voice notes require E2E mode, sending text instead`);
|
|
1656
|
+
const text = payload.text ?? payload.body;
|
|
1657
|
+
if (text) {
|
|
1658
|
+
await replyClient.sendSimple(from, text);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1565
1661
|
}
|
|
1566
|
-
|
|
1567
|
-
|
|
1662
|
+
catch (audioErr) {
|
|
1663
|
+
api.logger?.error?.(`Threema: error sending voice note: ${audioErr.message}`);
|
|
1664
|
+
// Fallback to text on error
|
|
1665
|
+
const text = payload.text ?? payload.body;
|
|
1666
|
+
if (text) {
|
|
1667
|
+
if (replyClient.isE2EEnabled) {
|
|
1668
|
+
await replyClient.sendE2E(from, text);
|
|
1669
|
+
}
|
|
1670
|
+
else {
|
|
1671
|
+
await replyClient.sendSimple(from, text);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1568
1674
|
}
|
|
1569
1675
|
}
|
|
1570
1676
|
else {
|
|
1571
|
-
//
|
|
1572
|
-
const
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
}
|
|
1579
|
-
let splitIdx = remaining.lastIndexOf("\n", limit);
|
|
1580
|
-
if (splitIdx <= 0)
|
|
1581
|
-
splitIdx = limit;
|
|
1582
|
-
chunks.push(remaining.slice(0, splitIdx));
|
|
1583
|
-
remaining = remaining.slice(splitIdx).replace(/^\n/, "");
|
|
1584
|
-
}
|
|
1585
|
-
for (const chunk of chunks) {
|
|
1677
|
+
// Send as text (existing logic)
|
|
1678
|
+
const text = payload.text ?? payload.body;
|
|
1679
|
+
if (!text)
|
|
1680
|
+
return;
|
|
1681
|
+
// Chunk long replies if needed
|
|
1682
|
+
const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
|
|
1683
|
+
if (text.length <= limit) {
|
|
1586
1684
|
if (replyClient.isE2EEnabled) {
|
|
1587
|
-
await replyClient.sendE2E(from,
|
|
1685
|
+
await replyClient.sendE2E(from, text);
|
|
1588
1686
|
}
|
|
1589
1687
|
else {
|
|
1590
|
-
await replyClient.sendSimple(from,
|
|
1688
|
+
await replyClient.sendSimple(from, text);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
else {
|
|
1692
|
+
// Split into chunks at newline boundaries
|
|
1693
|
+
const chunks = [];
|
|
1694
|
+
let remaining = text;
|
|
1695
|
+
while (remaining.length > 0) {
|
|
1696
|
+
if (remaining.length <= limit) {
|
|
1697
|
+
chunks.push(remaining);
|
|
1698
|
+
break;
|
|
1699
|
+
}
|
|
1700
|
+
let splitIdx = remaining.lastIndexOf("\n", limit);
|
|
1701
|
+
if (splitIdx <= 0)
|
|
1702
|
+
splitIdx = limit;
|
|
1703
|
+
chunks.push(remaining.slice(0, splitIdx));
|
|
1704
|
+
remaining = remaining.slice(splitIdx).replace(/^\n/, "");
|
|
1705
|
+
}
|
|
1706
|
+
for (const chunk of chunks) {
|
|
1707
|
+
if (replyClient.isE2EEnabled) {
|
|
1708
|
+
await replyClient.sendE2E(from, chunk);
|
|
1709
|
+
}
|
|
1710
|
+
else {
|
|
1711
|
+
await replyClient.sendSimple(from, chunk);
|
|
1712
|
+
}
|
|
1591
1713
|
}
|
|
1592
1714
|
}
|
|
1593
1715
|
}
|
|
@@ -1677,11 +1799,22 @@ export default function register(api) {
|
|
|
1677
1799
|
// session, runs the agent in-context, and lets us reply via
|
|
1678
1800
|
// dispatchReplyWithBufferedBlockDispatcher just like text.
|
|
1679
1801
|
const channelRuntime = runtime?.channel;
|
|
1680
|
-
if (channelRuntime?.
|
|
1802
|
+
if (channelRuntime?.routing?.resolveAgentRoute
|
|
1803
|
+
&& channelRuntime?.routing?.buildAgentSessionKey
|
|
1804
|
+
&& channelRuntime?.reply?.finalizeInboundContext
|
|
1681
1805
|
&& channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
1682
1806
|
try {
|
|
1683
1807
|
const currentCfg = runtime.config.loadConfig();
|
|
1684
|
-
|
|
1808
|
+
// Resolve the same agent route + session key the text path uses
|
|
1809
|
+
// so file inbounds end up in the live Threema DM session.
|
|
1810
|
+
const route = channelRuntime.routing.resolveAgentRoute({
|
|
1811
|
+
cfg: currentCfg,
|
|
1812
|
+
channel: "threema",
|
|
1813
|
+
accountId: "default",
|
|
1814
|
+
peer: { kind: "direct", id: from },
|
|
1815
|
+
});
|
|
1816
|
+
const sessionKey = channelRuntime.routing.buildAgentSessionKey({
|
|
1817
|
+
agentId: route.agentId,
|
|
1685
1818
|
channel: "threema",
|
|
1686
1819
|
accountId: "default",
|
|
1687
1820
|
peer: { kind: "direct", id: from },
|
|
@@ -1719,38 +1852,87 @@ export default function register(api) {
|
|
|
1719
1852
|
cfg: currentCfg,
|
|
1720
1853
|
dispatcherOptions: {
|
|
1721
1854
|
deliver: async (payload) => {
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1855
|
+
// Check if reply contains audio that should be sent as voice note
|
|
1856
|
+
const isAudioAsVoice = payload.audioAsVoice === true && payload.mediaUrl;
|
|
1857
|
+
if (isAudioAsVoice && payload.mediaUrl) {
|
|
1858
|
+
// Send as voice note
|
|
1859
|
+
try {
|
|
1860
|
+
if (replyClient.isE2EEnabled) {
|
|
1861
|
+
// Load audio from mediaUrl and send via sendVoiceNote
|
|
1862
|
+
const audioPath = payload.mediaUrl;
|
|
1863
|
+
if (fs.existsSync(audioPath)) {
|
|
1864
|
+
const audioBuffer = fs.readFileSync(audioPath);
|
|
1865
|
+
const mimeType = payload.mediaMimeType ?? "audio/aac";
|
|
1866
|
+
const caption = payload.text ?? payload.body ?? undefined;
|
|
1867
|
+
await replyClient.sendVoiceNote(from, audioBuffer, mimeType, caption);
|
|
1868
|
+
}
|
|
1869
|
+
else {
|
|
1870
|
+
api.logger?.warn?.(`Threema: audio file not found at ${audioPath}, falling back to text`);
|
|
1871
|
+
// Fallback to text if audio file not found
|
|
1872
|
+
const text = payload.text ?? payload.body;
|
|
1873
|
+
if (text) {
|
|
1874
|
+
await replyClient.sendE2E(from, text);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
else {
|
|
1879
|
+
// Voice notes only work in E2E mode; fallback to text in basic mode
|
|
1880
|
+
api.logger?.info?.(`Threema: voice notes require E2E mode, sending text instead`);
|
|
1881
|
+
const text = payload.text ?? payload.body;
|
|
1882
|
+
if (text) {
|
|
1883
|
+
await replyClient.sendSimple(from, text);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1729
1886
|
}
|
|
1730
|
-
|
|
1731
|
-
|
|
1887
|
+
catch (audioErr) {
|
|
1888
|
+
api.logger?.error?.(`Threema: error sending voice note: ${audioErr.message}`);
|
|
1889
|
+
// Fallback to text on error
|
|
1890
|
+
const text = payload.text ?? payload.body;
|
|
1891
|
+
if (text) {
|
|
1892
|
+
if (replyClient.isE2EEnabled) {
|
|
1893
|
+
await replyClient.sendE2E(from, text);
|
|
1894
|
+
}
|
|
1895
|
+
else {
|
|
1896
|
+
await replyClient.sendSimple(from, text);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1732
1899
|
}
|
|
1733
1900
|
}
|
|
1734
1901
|
else {
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
}
|
|
1742
|
-
let splitIdx = remaining.lastIndexOf("\n", limit);
|
|
1743
|
-
if (splitIdx <= 0)
|
|
1744
|
-
splitIdx = limit;
|
|
1745
|
-
chunks.push(remaining.slice(0, splitIdx));
|
|
1746
|
-
remaining = remaining.slice(splitIdx).replace(/^\n/, "");
|
|
1747
|
-
}
|
|
1748
|
-
for (const chunk of chunks) {
|
|
1902
|
+
// Send as text (existing logic)
|
|
1903
|
+
const text = payload.text ?? payload.body;
|
|
1904
|
+
if (!text)
|
|
1905
|
+
return;
|
|
1906
|
+
const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
|
|
1907
|
+
if (text.length <= limit) {
|
|
1749
1908
|
if (replyClient.isE2EEnabled) {
|
|
1750
|
-
await replyClient.sendE2E(from,
|
|
1909
|
+
await replyClient.sendE2E(from, text);
|
|
1751
1910
|
}
|
|
1752
1911
|
else {
|
|
1753
|
-
await replyClient.sendSimple(from,
|
|
1912
|
+
await replyClient.sendSimple(from, text);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
else {
|
|
1916
|
+
const chunks = [];
|
|
1917
|
+
let remaining = text;
|
|
1918
|
+
while (remaining.length > 0) {
|
|
1919
|
+
if (remaining.length <= limit) {
|
|
1920
|
+
chunks.push(remaining);
|
|
1921
|
+
break;
|
|
1922
|
+
}
|
|
1923
|
+
let splitIdx = remaining.lastIndexOf("\n", limit);
|
|
1924
|
+
if (splitIdx <= 0)
|
|
1925
|
+
splitIdx = limit;
|
|
1926
|
+
chunks.push(remaining.slice(0, splitIdx));
|
|
1927
|
+
remaining = remaining.slice(splitIdx).replace(/^\n/, "");
|
|
1928
|
+
}
|
|
1929
|
+
for (const chunk of chunks) {
|
|
1930
|
+
if (replyClient.isE2EEnabled) {
|
|
1931
|
+
await replyClient.sendE2E(from, chunk);
|
|
1932
|
+
}
|
|
1933
|
+
else {
|
|
1934
|
+
await replyClient.sendSimple(from, chunk);
|
|
1935
|
+
}
|
|
1754
1936
|
}
|
|
1755
1937
|
}
|
|
1756
1938
|
}
|
package/index.ts
CHANGED
|
@@ -525,6 +525,98 @@ class ThreemaClient {
|
|
|
525
525
|
return decrypted || null;
|
|
526
526
|
}
|
|
527
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Send a voice note message (audio file with voice message rendering type).
|
|
530
|
+
* Type 0x17 file message with j=1 (media rendering) and audio MIME type.
|
|
531
|
+
* Suitable for Whisper transcriptions or agent-generated TTS audio.
|
|
532
|
+
*/
|
|
533
|
+
async sendVoiceNote(
|
|
534
|
+
to: string,
|
|
535
|
+
audioBuffer: Buffer,
|
|
536
|
+
mimeType: string = "audio/aac",
|
|
537
|
+
caption?: string
|
|
538
|
+
): Promise<string> {
|
|
539
|
+
if (!this.privateKey) {
|
|
540
|
+
throw new Error("E2E mode requires privateKey configuration");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const recipientPubKey = await this.getPublicKey(to);
|
|
544
|
+
|
|
545
|
+
// Generate random symmetric key for file encryption
|
|
546
|
+
const fileKey = nacl.randomBytes(32);
|
|
547
|
+
// Threema FILE_NONCE: 23 zero bytes + 0x01
|
|
548
|
+
const fileNonce = new Uint8Array(24);
|
|
549
|
+
fileNonce[23] = 0x01;
|
|
550
|
+
|
|
551
|
+
// Encrypt the audio with secretbox
|
|
552
|
+
const encryptedAudio = nacl.secretbox(new Uint8Array(audioBuffer), fileNonce, fileKey);
|
|
553
|
+
|
|
554
|
+
// Upload encrypted blob
|
|
555
|
+
const blobId = await this.uploadBlob(encryptedAudio);
|
|
556
|
+
|
|
557
|
+
// Create file message JSON for voice note
|
|
558
|
+
// j=1 marks it as media (voice message bubble in UI)
|
|
559
|
+
const fileMsg: ThreemaFileMessage = {
|
|
560
|
+
b: blobId,
|
|
561
|
+
k: bytesToHex(fileKey),
|
|
562
|
+
m: mimeType,
|
|
563
|
+
n: `voice.${this.getMimeExtension(mimeType)}`,
|
|
564
|
+
s: audioBuffer.length,
|
|
565
|
+
j: 1, // 1 = render as media (voice message bubble)
|
|
566
|
+
i: 1, // deprecated but needed for older clients
|
|
567
|
+
};
|
|
568
|
+
if (caption) {
|
|
569
|
+
fileMsg.d = caption;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const fileMsgJson = JSON.stringify(fileMsg);
|
|
573
|
+
const fileMsgBytes = decodeUTF8(fileMsgJson);
|
|
574
|
+
|
|
575
|
+
// Create E2E payload (type 0x17 = file message)
|
|
576
|
+
const payload = buildE2EPayload(0x17, fileMsgBytes);
|
|
577
|
+
|
|
578
|
+
// Generate nonce and encrypt with NaCl box
|
|
579
|
+
const nonce = nacl.randomBytes(24);
|
|
580
|
+
const box = nacl.box(payload, nonce, recipientPubKey, this.privateKey);
|
|
581
|
+
|
|
582
|
+
const params = new URLSearchParams({
|
|
583
|
+
from: this.gatewayId,
|
|
584
|
+
to,
|
|
585
|
+
nonce: bytesToHex(nonce),
|
|
586
|
+
box: bytesToHex(box),
|
|
587
|
+
secret: this.secretKey,
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const url = `${THREEMA_API_BASE}/send_e2e`;
|
|
591
|
+
const res = await fetch(url, {
|
|
592
|
+
method: "POST",
|
|
593
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
594
|
+
body: params.toString(),
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
if (!res.ok) {
|
|
598
|
+
// Don't log response body (may contain secrets)
|
|
599
|
+
throw new Error(`Threema E2E API error ${res.status}`);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return res.text();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Get file extension for MIME type
|
|
607
|
+
*/
|
|
608
|
+
private getMimeExtension(mimeType: string): string {
|
|
609
|
+
const mimeMap: Record<string, string> = {
|
|
610
|
+
"audio/aac": "aac",
|
|
611
|
+
"audio/mpeg": "mp3",
|
|
612
|
+
"audio/wav": "wav",
|
|
613
|
+
"audio/ogg": "ogg",
|
|
614
|
+
"audio/m4a": "m4a",
|
|
615
|
+
"audio/webm": "webm",
|
|
616
|
+
};
|
|
617
|
+
return mimeMap[mimeType.toLowerCase()] || "m4a";
|
|
618
|
+
}
|
|
619
|
+
|
|
528
620
|
/**
|
|
529
621
|
* Send a text message (Basic mode - server-side encryption)
|
|
530
622
|
*/
|
|
@@ -2000,35 +2092,80 @@ export default function register(api: any) {
|
|
|
2000
2092
|
cfg: currentCfg,
|
|
2001
2093
|
dispatcherOptions: {
|
|
2002
2094
|
deliver: async (payload: any) => {
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2095
|
+
// Check if reply contains audio that should be sent as voice note
|
|
2096
|
+
const isAudioAsVoice = payload.audioAsVoice === true && payload.mediaUrl;
|
|
2097
|
+
|
|
2098
|
+
if (isAudioAsVoice && payload.mediaUrl) {
|
|
2099
|
+
// Send as voice note
|
|
2100
|
+
try {
|
|
2101
|
+
if (replyClient.isE2EEnabled) {
|
|
2102
|
+
// Load audio from mediaUrl and send via sendVoiceNote
|
|
2103
|
+
const audioPath = payload.mediaUrl;
|
|
2104
|
+
if (fs.existsSync(audioPath)) {
|
|
2105
|
+
const audioBuffer = fs.readFileSync(audioPath);
|
|
2106
|
+
const mimeType = payload.mediaMimeType ?? "audio/aac";
|
|
2107
|
+
const caption = payload.text ?? payload.body ?? undefined;
|
|
2108
|
+
await replyClient.sendVoiceNote(from, audioBuffer, mimeType, caption);
|
|
2109
|
+
} else {
|
|
2110
|
+
api.logger?.warn?.(`Threema: audio file not found at ${audioPath}, falling back to text`);
|
|
2111
|
+
// Fallback to text if audio file not found
|
|
2112
|
+
const text = payload.text ?? payload.body;
|
|
2113
|
+
if (text) {
|
|
2114
|
+
await replyClient.sendE2E(from, text);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
} else {
|
|
2118
|
+
// Voice notes only work in E2E mode; fallback to text in basic mode
|
|
2119
|
+
api.logger?.info?.(`Threema: voice notes require E2E mode, sending text instead`);
|
|
2120
|
+
const text = payload.text ?? payload.body;
|
|
2121
|
+
if (text) {
|
|
2122
|
+
await replyClient.sendSimple(from, text);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
} catch (audioErr: any) {
|
|
2126
|
+
api.logger?.error?.(`Threema: error sending voice note: ${audioErr.message}`);
|
|
2127
|
+
// Fallback to text on error
|
|
2128
|
+
const text = payload.text ?? payload.body;
|
|
2129
|
+
if (text) {
|
|
2130
|
+
if (replyClient.isE2EEnabled) {
|
|
2131
|
+
await replyClient.sendE2E(from, text);
|
|
2132
|
+
} else {
|
|
2133
|
+
await replyClient.sendSimple(from, text);
|
|
2134
|
+
}
|
|
2021
2135
|
}
|
|
2022
|
-
let splitIdx = remaining.lastIndexOf("\n", limit);
|
|
2023
|
-
if (splitIdx <= 0) splitIdx = limit;
|
|
2024
|
-
chunks.push(remaining.slice(0, splitIdx));
|
|
2025
|
-
remaining = remaining.slice(splitIdx).replace(/^\n/, "");
|
|
2026
2136
|
}
|
|
2027
|
-
|
|
2137
|
+
} else {
|
|
2138
|
+
// Send as text (existing logic)
|
|
2139
|
+
const text = payload.text ?? payload.body;
|
|
2140
|
+
if (!text) return;
|
|
2141
|
+
// Chunk long replies if needed
|
|
2142
|
+
const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
|
|
2143
|
+
if (text.length <= limit) {
|
|
2028
2144
|
if (replyClient.isE2EEnabled) {
|
|
2029
|
-
await replyClient.sendE2E(from,
|
|
2145
|
+
await replyClient.sendE2E(from, text);
|
|
2030
2146
|
} else {
|
|
2031
|
-
await replyClient.sendSimple(from,
|
|
2147
|
+
await replyClient.sendSimple(from, text);
|
|
2148
|
+
}
|
|
2149
|
+
} else {
|
|
2150
|
+
// Split into chunks at newline boundaries
|
|
2151
|
+
const chunks: string[] = [];
|
|
2152
|
+
let remaining = text;
|
|
2153
|
+
while (remaining.length > 0) {
|
|
2154
|
+
if (remaining.length <= limit) {
|
|
2155
|
+
chunks.push(remaining);
|
|
2156
|
+
break;
|
|
2157
|
+
}
|
|
2158
|
+
let splitIdx = remaining.lastIndexOf("\n", limit);
|
|
2159
|
+
if (splitIdx <= 0) splitIdx = limit;
|
|
2160
|
+
chunks.push(remaining.slice(0, splitIdx));
|
|
2161
|
+
remaining = remaining.slice(splitIdx).replace(/^\n/, "");
|
|
2162
|
+
}
|
|
2163
|
+
for (const chunk of chunks) {
|
|
2164
|
+
if (replyClient.isE2EEnabled) {
|
|
2165
|
+
await replyClient.sendE2E(from, chunk);
|
|
2166
|
+
} else {
|
|
2167
|
+
await replyClient.sendSimple(from, chunk);
|
|
2168
|
+
}
|
|
2032
2169
|
}
|
|
2033
2170
|
}
|
|
2034
2171
|
}
|
|
@@ -2121,11 +2258,24 @@ export default function register(api: any) {
|
|
|
2121
2258
|
// session, runs the agent in-context, and lets us reply via
|
|
2122
2259
|
// dispatchReplyWithBufferedBlockDispatcher just like text.
|
|
2123
2260
|
const channelRuntime = runtime?.channel;
|
|
2124
|
-
if (channelRuntime?.
|
|
2261
|
+
if (channelRuntime?.routing?.resolveAgentRoute
|
|
2262
|
+
&& channelRuntime?.routing?.buildAgentSessionKey
|
|
2263
|
+
&& channelRuntime?.reply?.finalizeInboundContext
|
|
2125
2264
|
&& channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
2126
2265
|
try {
|
|
2127
2266
|
const currentCfg = runtime.config.loadConfig();
|
|
2128
|
-
|
|
2267
|
+
|
|
2268
|
+
// Resolve the same agent route + session key the text path uses
|
|
2269
|
+
// so file inbounds end up in the live Threema DM session.
|
|
2270
|
+
const route = channelRuntime.routing.resolveAgentRoute({
|
|
2271
|
+
cfg: currentCfg,
|
|
2272
|
+
channel: "threema",
|
|
2273
|
+
accountId: "default",
|
|
2274
|
+
peer: { kind: "direct", id: from },
|
|
2275
|
+
});
|
|
2276
|
+
|
|
2277
|
+
const sessionKey = channelRuntime.routing.buildAgentSessionKey({
|
|
2278
|
+
agentId: route.agentId,
|
|
2129
2279
|
channel: "threema",
|
|
2130
2280
|
accountId: "default",
|
|
2131
2281
|
peer: { kind: "direct", id: from },
|
|
@@ -2166,33 +2316,78 @@ export default function register(api: any) {
|
|
|
2166
2316
|
cfg: currentCfg,
|
|
2167
2317
|
dispatcherOptions: {
|
|
2168
2318
|
deliver: async (payload: any) => {
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
if (
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2319
|
+
// Check if reply contains audio that should be sent as voice note
|
|
2320
|
+
const isAudioAsVoice = payload.audioAsVoice === true && payload.mediaUrl;
|
|
2321
|
+
|
|
2322
|
+
if (isAudioAsVoice && payload.mediaUrl) {
|
|
2323
|
+
// Send as voice note
|
|
2324
|
+
try {
|
|
2325
|
+
if (replyClient.isE2EEnabled) {
|
|
2326
|
+
// Load audio from mediaUrl and send via sendVoiceNote
|
|
2327
|
+
const audioPath = payload.mediaUrl;
|
|
2328
|
+
if (fs.existsSync(audioPath)) {
|
|
2329
|
+
const audioBuffer = fs.readFileSync(audioPath);
|
|
2330
|
+
const mimeType = payload.mediaMimeType ?? "audio/aac";
|
|
2331
|
+
const caption = payload.text ?? payload.body ?? undefined;
|
|
2332
|
+
await replyClient.sendVoiceNote(from, audioBuffer, mimeType, caption);
|
|
2333
|
+
} else {
|
|
2334
|
+
api.logger?.warn?.(`Threema: audio file not found at ${audioPath}, falling back to text`);
|
|
2335
|
+
// Fallback to text if audio file not found
|
|
2336
|
+
const text = payload.text ?? payload.body;
|
|
2337
|
+
if (text) {
|
|
2338
|
+
await replyClient.sendE2E(from, text);
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
} else {
|
|
2342
|
+
// Voice notes only work in E2E mode; fallback to text in basic mode
|
|
2343
|
+
api.logger?.info?.(`Threema: voice notes require E2E mode, sending text instead`);
|
|
2344
|
+
const text = payload.text ?? payload.body;
|
|
2345
|
+
if (text) {
|
|
2346
|
+
await replyClient.sendSimple(from, text);
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
} catch (audioErr: any) {
|
|
2350
|
+
api.logger?.error?.(`Threema: error sending voice note: ${audioErr.message}`);
|
|
2351
|
+
// Fallback to text on error
|
|
2352
|
+
const text = payload.text ?? payload.body;
|
|
2353
|
+
if (text) {
|
|
2354
|
+
if (replyClient.isE2EEnabled) {
|
|
2355
|
+
await replyClient.sendE2E(from, text);
|
|
2356
|
+
} else {
|
|
2357
|
+
await replyClient.sendSimple(from, text);
|
|
2358
|
+
}
|
|
2185
2359
|
}
|
|
2186
|
-
let splitIdx = remaining.lastIndexOf("\n", limit);
|
|
2187
|
-
if (splitIdx <= 0) splitIdx = limit;
|
|
2188
|
-
chunks.push(remaining.slice(0, splitIdx));
|
|
2189
|
-
remaining = remaining.slice(splitIdx).replace(/^\n/, "");
|
|
2190
2360
|
}
|
|
2191
|
-
|
|
2361
|
+
} else {
|
|
2362
|
+
// Send as text (existing logic)
|
|
2363
|
+
const text = payload.text ?? payload.body;
|
|
2364
|
+
if (!text) return;
|
|
2365
|
+
const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
|
|
2366
|
+
if (text.length <= limit) {
|
|
2192
2367
|
if (replyClient.isE2EEnabled) {
|
|
2193
|
-
await replyClient.sendE2E(from,
|
|
2368
|
+
await replyClient.sendE2E(from, text);
|
|
2194
2369
|
} else {
|
|
2195
|
-
await replyClient.sendSimple(from,
|
|
2370
|
+
await replyClient.sendSimple(from, text);
|
|
2371
|
+
}
|
|
2372
|
+
} else {
|
|
2373
|
+
const chunks: string[] = [];
|
|
2374
|
+
let remaining = text;
|
|
2375
|
+
while (remaining.length > 0) {
|
|
2376
|
+
if (remaining.length <= limit) {
|
|
2377
|
+
chunks.push(remaining);
|
|
2378
|
+
break;
|
|
2379
|
+
}
|
|
2380
|
+
let splitIdx = remaining.lastIndexOf("\n", limit);
|
|
2381
|
+
if (splitIdx <= 0) splitIdx = limit;
|
|
2382
|
+
chunks.push(remaining.slice(0, splitIdx));
|
|
2383
|
+
remaining = remaining.slice(splitIdx).replace(/^\n/, "");
|
|
2384
|
+
}
|
|
2385
|
+
for (const chunk of chunks) {
|
|
2386
|
+
if (replyClient.isE2EEnabled) {
|
|
2387
|
+
await replyClient.sendE2E(from, chunk);
|
|
2388
|
+
} else {
|
|
2389
|
+
await replyClient.sendSimple(from, chunk);
|
|
2390
|
+
}
|
|
2196
2391
|
}
|
|
2197
2392
|
}
|
|
2198
2393
|
}
|
package/openclaw.plugin.json
CHANGED