openclaw-threema 0.6.4 → 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 +11 -0
- package/dist/index.js +223 -52
- package/index.ts +230 -48
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
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
|
+
|
|
3
14
|
## 0.6.4 (2026-05-04)
|
|
4
15
|
|
|
5
16
|
### 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
|
}
|
|
@@ -1730,38 +1852,87 @@ export default function register(api) {
|
|
|
1730
1852
|
cfg: currentCfg,
|
|
1731
1853
|
dispatcherOptions: {
|
|
1732
1854
|
deliver: async (payload) => {
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
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
|
+
}
|
|
1740
1886
|
}
|
|
1741
|
-
|
|
1742
|
-
|
|
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
|
+
}
|
|
1743
1899
|
}
|
|
1744
1900
|
}
|
|
1745
1901
|
else {
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
}
|
|
1753
|
-
let splitIdx = remaining.lastIndexOf("\n", limit);
|
|
1754
|
-
if (splitIdx <= 0)
|
|
1755
|
-
splitIdx = limit;
|
|
1756
|
-
chunks.push(remaining.slice(0, splitIdx));
|
|
1757
|
-
remaining = remaining.slice(splitIdx).replace(/^\n/, "");
|
|
1758
|
-
}
|
|
1759
|
-
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) {
|
|
1760
1908
|
if (replyClient.isE2EEnabled) {
|
|
1761
|
-
await replyClient.sendE2E(from,
|
|
1909
|
+
await replyClient.sendE2E(from, text);
|
|
1762
1910
|
}
|
|
1763
1911
|
else {
|
|
1764
|
-
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
|
+
}
|
|
1765
1936
|
}
|
|
1766
1937
|
}
|
|
1767
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
|
}
|
|
@@ -2179,33 +2316,78 @@ export default function register(api: any) {
|
|
|
2179
2316
|
cfg: currentCfg,
|
|
2180
2317
|
dispatcherOptions: {
|
|
2181
2318
|
deliver: async (payload: any) => {
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
if (
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
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
|
+
}
|
|
2198
2359
|
}
|
|
2199
|
-
let splitIdx = remaining.lastIndexOf("\n", limit);
|
|
2200
|
-
if (splitIdx <= 0) splitIdx = limit;
|
|
2201
|
-
chunks.push(remaining.slice(0, splitIdx));
|
|
2202
|
-
remaining = remaining.slice(splitIdx).replace(/^\n/, "");
|
|
2203
2360
|
}
|
|
2204
|
-
|
|
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) {
|
|
2205
2367
|
if (replyClient.isE2EEnabled) {
|
|
2206
|
-
await replyClient.sendE2E(from,
|
|
2368
|
+
await replyClient.sendE2E(from, text);
|
|
2207
2369
|
} else {
|
|
2208
|
-
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
|
+
}
|
|
2209
2391
|
}
|
|
2210
2392
|
}
|
|
2211
2393
|
}
|
package/openclaw.plugin.json
CHANGED