openclaw-threema 0.6.5 → 0.6.6
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 +83 -7
- package/index.ts +93 -7
- 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.6 (2026-05-04)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Idempotency Cache for Webhook Replay Protection**: Implements replay-attack protection against Threema webhook retries.
|
|
7
|
+
- New message-ID deduplication mechanism with configurable TTL (24h) and cache size (500 entries max).
|
|
8
|
+
- Automatic pruning of expired entries during check.
|
|
9
|
+
- Disk persistence via `~/.openclaw/extensions/threema/.idempotency-cache/messageids.json` to survive plugin reloads.
|
|
10
|
+
- Throttled writes (max 1 per 5 seconds) to prevent excessive I/O.
|
|
11
|
+
- Prevents duplicate processing when Threema Gateway retries a failed webhook delivery.
|
|
12
|
+
- Solves the issue where Plugin reloads during `npm publish` + temporary 5xx errors could cause the same message to be processed twice.
|
|
13
|
+
|
|
3
14
|
## 0.6.5 (2026-05-04)
|
|
4
15
|
|
|
5
16
|
### Added
|
package/dist/index.js
CHANGED
|
@@ -157,10 +157,12 @@ function composeBodyForAgent(userText, cfg) {
|
|
|
157
157
|
}
|
|
158
158
|
// Allowed base directory for local media files (exfiltration protection)
|
|
159
159
|
const MEDIA_ALLOWED_BASE = path.join(process.env.HOME || "/tmp", ".openclaw", "media");
|
|
160
|
+
// Extension state directory for persistent caches
|
|
161
|
+
const EXTENSION_STATE_DIR = path.join(process.env.HOME || "/tmp", ".openclaw", "extensions", "threema");
|
|
160
162
|
// Message-ID dedup cache (replay protection): messageId -> timestamp
|
|
161
163
|
const seenMsgIds = new Map();
|
|
162
|
-
const MSG_ID_TTL_MS =
|
|
163
|
-
const MSG_ID_CACHE_MAX =
|
|
164
|
+
const MSG_ID_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
165
|
+
const MSG_ID_CACHE_MAX = 500;
|
|
164
166
|
// Audio MIME types that should be transcribed
|
|
165
167
|
const AUDIO_MIME_TYPES = [
|
|
166
168
|
"audio/aac",
|
|
@@ -554,15 +556,85 @@ function buildE2EPayload(type, inner) {
|
|
|
554
556
|
* Check if a message ID has been seen recently (replay protection)
|
|
555
557
|
* Returns true if duplicate (should be ignored)
|
|
556
558
|
*/
|
|
559
|
+
// Idempotency cache directory
|
|
560
|
+
const CACHE_DIR = path.join(EXTENSION_STATE_DIR, ".idempotency-cache");
|
|
561
|
+
const CACHE_FILE = path.join(CACHE_DIR, "messageids.json");
|
|
562
|
+
let lastCacheSave = 0;
|
|
563
|
+
const CACHE_SAVE_THROTTLE_MS = 5000; // Max 1 write per 5 sec
|
|
564
|
+
/**
|
|
565
|
+
* Load idempotency cache from disk if available and fresh
|
|
566
|
+
*/
|
|
567
|
+
function loadIdempotencyCache() {
|
|
568
|
+
try {
|
|
569
|
+
if (!fs.existsSync(CACHE_FILE))
|
|
570
|
+
return;
|
|
571
|
+
const data = fs.readFileSync(CACHE_FILE, "utf-8");
|
|
572
|
+
const parsed = JSON.parse(data);
|
|
573
|
+
if (!parsed || typeof parsed !== "object")
|
|
574
|
+
return;
|
|
575
|
+
const now = Date.now();
|
|
576
|
+
for (const [id, ts] of Object.entries(parsed)) {
|
|
577
|
+
const timestamp = Number(ts);
|
|
578
|
+
// Only load entries that are still within TTL
|
|
579
|
+
if (!isNaN(timestamp) && now - timestamp < MSG_ID_TTL_MS) {
|
|
580
|
+
seenMsgIds.set(id, timestamp);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
// Silently skip if cache file is corrupted or unreadable
|
|
586
|
+
// Next write will overwrite it
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Save idempotency cache to disk (throttled)
|
|
591
|
+
*/
|
|
592
|
+
function saveIdempotencyCache() {
|
|
593
|
+
const now = Date.now();
|
|
594
|
+
if (now - lastCacheSave < CACHE_SAVE_THROTTLE_MS) {
|
|
595
|
+
return; // Skip this write, within throttle window
|
|
596
|
+
}
|
|
597
|
+
lastCacheSave = now;
|
|
598
|
+
try {
|
|
599
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
600
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
601
|
+
}
|
|
602
|
+
const obj = {};
|
|
603
|
+
for (const [id, ts] of seenMsgIds) {
|
|
604
|
+
obj[id] = ts;
|
|
605
|
+
}
|
|
606
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify(obj, null, 2), "utf-8");
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
// Silently skip if write fails; in-memory cache is still valid
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Check if message has been seen before (idempotency check)
|
|
614
|
+
* Returns true if duplicate (should skip), false if new (should process)
|
|
615
|
+
*/
|
|
557
616
|
function isDuplicateMsgId(messageId) {
|
|
558
617
|
const now = Date.now();
|
|
559
|
-
//
|
|
560
|
-
|
|
618
|
+
// Prune entries older than TTL
|
|
619
|
+
for (const [id, ts] of seenMsgIds) {
|
|
620
|
+
if (now - ts > MSG_ID_TTL_MS) {
|
|
621
|
+
seenMsgIds.delete(id);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
// If cache is still too large, evict oldest entries
|
|
625
|
+
if (seenMsgIds.size >= MSG_ID_CACHE_MAX) {
|
|
626
|
+
// Find and remove the oldest entry
|
|
627
|
+
let oldest = messageId;
|
|
628
|
+
let oldestTs = now;
|
|
561
629
|
for (const [id, ts] of seenMsgIds) {
|
|
562
|
-
if (
|
|
563
|
-
|
|
630
|
+
if (ts < oldestTs) {
|
|
631
|
+
oldest = id;
|
|
632
|
+
oldestTs = ts;
|
|
564
633
|
}
|
|
565
634
|
}
|
|
635
|
+
if (oldest !== messageId) {
|
|
636
|
+
seenMsgIds.delete(oldest);
|
|
637
|
+
}
|
|
566
638
|
}
|
|
567
639
|
// Check if seen
|
|
568
640
|
const seenAt = seenMsgIds.get(messageId);
|
|
@@ -571,6 +643,7 @@ function isDuplicateMsgId(messageId) {
|
|
|
571
643
|
}
|
|
572
644
|
// Mark as seen
|
|
573
645
|
seenMsgIds.set(messageId, now);
|
|
646
|
+
saveIdempotencyCache(); // Throttled write
|
|
574
647
|
return false;
|
|
575
648
|
}
|
|
576
649
|
/**
|
|
@@ -1442,10 +1515,13 @@ const threemaChannel = {
|
|
|
1442
1515
|
// ============================================================================
|
|
1443
1516
|
export const id = "threema";
|
|
1444
1517
|
export const name = "Threema Gateway";
|
|
1445
|
-
export const version = "0.6.
|
|
1518
|
+
export const version = "0.6.6";
|
|
1446
1519
|
export const description = "Threema messaging channel via Threema Gateway API (E2E encrypted, with media support)";
|
|
1447
1520
|
export default function register(api) {
|
|
1448
1521
|
try {
|
|
1522
|
+
// Load idempotency cache from disk (if available)
|
|
1523
|
+
loadIdempotencyCache();
|
|
1524
|
+
api.logger?.debug?.("Threema: idempotency cache loaded from disk");
|
|
1449
1525
|
const config = api.config;
|
|
1450
1526
|
const threemaCfg = getThreemaConfig(config);
|
|
1451
1527
|
const runtime = api.runtime;
|
package/index.ts
CHANGED
|
@@ -311,10 +311,18 @@ const MEDIA_ALLOWED_BASE = path.join(
|
|
|
311
311
|
"media"
|
|
312
312
|
);
|
|
313
313
|
|
|
314
|
+
// Extension state directory for persistent caches
|
|
315
|
+
const EXTENSION_STATE_DIR = path.join(
|
|
316
|
+
process.env.HOME || "/tmp",
|
|
317
|
+
".openclaw",
|
|
318
|
+
"extensions",
|
|
319
|
+
"threema"
|
|
320
|
+
);
|
|
321
|
+
|
|
314
322
|
// Message-ID dedup cache (replay protection): messageId -> timestamp
|
|
315
323
|
const seenMsgIds = new Map<string, number>();
|
|
316
|
-
const MSG_ID_TTL_MS =
|
|
317
|
-
const MSG_ID_CACHE_MAX =
|
|
324
|
+
const MSG_ID_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
325
|
+
const MSG_ID_CACHE_MAX = 500;
|
|
318
326
|
|
|
319
327
|
// Audio MIME types that should be transcribed
|
|
320
328
|
const AUDIO_MIME_TYPES = [
|
|
@@ -810,16 +818,89 @@ function buildE2EPayload(type: number, inner: Uint8Array): Uint8Array {
|
|
|
810
818
|
* Check if a message ID has been seen recently (replay protection)
|
|
811
819
|
* Returns true if duplicate (should be ignored)
|
|
812
820
|
*/
|
|
821
|
+
// Idempotency cache directory
|
|
822
|
+
const CACHE_DIR = path.join(EXTENSION_STATE_DIR, ".idempotency-cache");
|
|
823
|
+
const CACHE_FILE = path.join(CACHE_DIR, "messageids.json");
|
|
824
|
+
let lastCacheSave = 0;
|
|
825
|
+
const CACHE_SAVE_THROTTLE_MS = 5000; // Max 1 write per 5 sec
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Load idempotency cache from disk if available and fresh
|
|
829
|
+
*/
|
|
830
|
+
function loadIdempotencyCache(): void {
|
|
831
|
+
try {
|
|
832
|
+
if (!fs.existsSync(CACHE_FILE)) return;
|
|
833
|
+
|
|
834
|
+
const data = fs.readFileSync(CACHE_FILE, "utf-8");
|
|
835
|
+
const parsed = JSON.parse(data);
|
|
836
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
837
|
+
|
|
838
|
+
const now = Date.now();
|
|
839
|
+
for (const [id, ts] of Object.entries(parsed)) {
|
|
840
|
+
const timestamp = Number(ts);
|
|
841
|
+
// Only load entries that are still within TTL
|
|
842
|
+
if (!isNaN(timestamp) && now - timestamp < MSG_ID_TTL_MS) {
|
|
843
|
+
seenMsgIds.set(id, timestamp);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
} catch (err: any) {
|
|
847
|
+
// Silently skip if cache file is corrupted or unreadable
|
|
848
|
+
// Next write will overwrite it
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Save idempotency cache to disk (throttled)
|
|
854
|
+
*/
|
|
855
|
+
function saveIdempotencyCache(): void {
|
|
856
|
+
const now = Date.now();
|
|
857
|
+
if (now - lastCacheSave < CACHE_SAVE_THROTTLE_MS) {
|
|
858
|
+
return; // Skip this write, within throttle window
|
|
859
|
+
}
|
|
860
|
+
lastCacheSave = now;
|
|
861
|
+
|
|
862
|
+
try {
|
|
863
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
864
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
865
|
+
}
|
|
866
|
+
const obj: Record<string, number> = {};
|
|
867
|
+
for (const [id, ts] of seenMsgIds) {
|
|
868
|
+
obj[id] = ts;
|
|
869
|
+
}
|
|
870
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify(obj, null, 2), "utf-8");
|
|
871
|
+
} catch (err: any) {
|
|
872
|
+
// Silently skip if write fails; in-memory cache is still valid
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Check if message has been seen before (idempotency check)
|
|
878
|
+
* Returns true if duplicate (should skip), false if new (should process)
|
|
879
|
+
*/
|
|
813
880
|
function isDuplicateMsgId(messageId: string): boolean {
|
|
814
881
|
const now = Date.now();
|
|
815
882
|
|
|
816
|
-
//
|
|
817
|
-
|
|
883
|
+
// Prune entries older than TTL
|
|
884
|
+
for (const [id, ts] of seenMsgIds) {
|
|
885
|
+
if (now - ts > MSG_ID_TTL_MS) {
|
|
886
|
+
seenMsgIds.delete(id);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// If cache is still too large, evict oldest entries
|
|
891
|
+
if (seenMsgIds.size >= MSG_ID_CACHE_MAX) {
|
|
892
|
+
// Find and remove the oldest entry
|
|
893
|
+
let oldest = messageId;
|
|
894
|
+
let oldestTs = now;
|
|
818
895
|
for (const [id, ts] of seenMsgIds) {
|
|
819
|
-
if (
|
|
820
|
-
|
|
896
|
+
if (ts < oldestTs) {
|
|
897
|
+
oldest = id;
|
|
898
|
+
oldestTs = ts;
|
|
821
899
|
}
|
|
822
900
|
}
|
|
901
|
+
if (oldest !== messageId) {
|
|
902
|
+
seenMsgIds.delete(oldest);
|
|
903
|
+
}
|
|
823
904
|
}
|
|
824
905
|
|
|
825
906
|
// Check if seen
|
|
@@ -830,6 +911,7 @@ function isDuplicateMsgId(messageId: string): boolean {
|
|
|
830
911
|
|
|
831
912
|
// Mark as seen
|
|
832
913
|
seenMsgIds.set(messageId, now);
|
|
914
|
+
saveIdempotencyCache(); // Throttled write
|
|
833
915
|
return false;
|
|
834
916
|
}
|
|
835
917
|
|
|
@@ -1872,12 +1954,16 @@ const threemaChannel = {
|
|
|
1872
1954
|
|
|
1873
1955
|
export const id = "threema";
|
|
1874
1956
|
export const name = "Threema Gateway";
|
|
1875
|
-
export const version = "0.6.
|
|
1957
|
+
export const version = "0.6.6";
|
|
1876
1958
|
export const description =
|
|
1877
1959
|
"Threema messaging channel via Threema Gateway API (E2E encrypted, with media support)";
|
|
1878
1960
|
|
|
1879
1961
|
export default function register(api: any) {
|
|
1880
1962
|
try {
|
|
1963
|
+
// Load idempotency cache from disk (if available)
|
|
1964
|
+
loadIdempotencyCache();
|
|
1965
|
+
api.logger?.debug?.("Threema: idempotency cache loaded from disk");
|
|
1966
|
+
|
|
1881
1967
|
const config = api.config as OpenClawConfig;
|
|
1882
1968
|
const threemaCfg = getThreemaConfig(config);
|
|
1883
1969
|
const runtime = api.runtime;
|
package/openclaw.plugin.json
CHANGED