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 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
- const text = payload.text ?? payload.body;
1558
- if (!text)
1559
- return;
1560
- // Chunk long replies if needed
1561
- const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
1562
- if (text.length <= limit) {
1563
- if (replyClient.isE2EEnabled) {
1564
- await replyClient.sendE2E(from, text);
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
- else {
1567
- await replyClient.sendSimple(from, text);
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
- // Split into chunks at newline boundaries
1572
- const chunks = [];
1573
- let remaining = text;
1574
- while (remaining.length > 0) {
1575
- if (remaining.length <= limit) {
1576
- chunks.push(remaining);
1577
- break;
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, chunk);
1685
+ await replyClient.sendE2E(from, text);
1588
1686
  }
1589
1687
  else {
1590
- await replyClient.sendSimple(from, chunk);
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
- const text = payload.text ?? payload.body;
1734
- if (!text)
1735
- return;
1736
- const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
1737
- if (text.length <= limit) {
1738
- if (replyClient.isE2EEnabled) {
1739
- await replyClient.sendE2E(from, text);
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
- else {
1742
- await replyClient.sendSimple(from, text);
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
- const chunks = [];
1747
- let remaining = text;
1748
- while (remaining.length > 0) {
1749
- if (remaining.length <= limit) {
1750
- chunks.push(remaining);
1751
- break;
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, chunk);
1909
+ await replyClient.sendE2E(from, text);
1762
1910
  }
1763
1911
  else {
1764
- await replyClient.sendSimple(from, chunk);
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
- const text = payload.text ?? payload.body;
2004
- if (!text) return;
2005
- // Chunk long replies if needed
2006
- const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
2007
- if (text.length <= limit) {
2008
- if (replyClient.isE2EEnabled) {
2009
- await replyClient.sendE2E(from, text);
2010
- } else {
2011
- await replyClient.sendSimple(from, text);
2012
- }
2013
- } else {
2014
- // Split into chunks at newline boundaries
2015
- const chunks: string[] = [];
2016
- let remaining = text;
2017
- while (remaining.length > 0) {
2018
- if (remaining.length <= limit) {
2019
- chunks.push(remaining);
2020
- break;
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
- for (const chunk of chunks) {
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, chunk);
2145
+ await replyClient.sendE2E(from, text);
2030
2146
  } else {
2031
- await replyClient.sendSimple(from, chunk);
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
- const text = payload.text ?? payload.body;
2183
- if (!text) return;
2184
- const limit = getThreemaConfig(currentCfg)?.textChunkLimit ?? 3500;
2185
- if (text.length <= limit) {
2186
- if (replyClient.isE2EEnabled) {
2187
- await replyClient.sendE2E(from, text);
2188
- } else {
2189
- await replyClient.sendSimple(from, text);
2190
- }
2191
- } else {
2192
- const chunks: string[] = [];
2193
- let remaining = text;
2194
- while (remaining.length > 0) {
2195
- if (remaining.length <= limit) {
2196
- chunks.push(remaining);
2197
- break;
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
- for (const chunk of chunks) {
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, chunk);
2368
+ await replyClient.sendE2E(from, text);
2207
2369
  } else {
2208
- await replyClient.sendSimple(from, chunk);
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
  }
@@ -2,7 +2,7 @@
2
2
  "id": "threema",
3
3
  "name": "Threema Gateway",
4
4
  "description": "Threema messaging channel via Threema Gateway API (E2E encrypted)",
5
- "version": "0.6.4",
5
+ "version": "0.6.5",
6
6
  "channels": [
7
7
  "threema"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-threema",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Threema Gateway channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",