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 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 = 15 * 60 * 1000; // 15 minutes
163
- const MSG_ID_CACHE_MAX = 5000;
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
- // Cleanup old entries if cache is too large
560
- if (seenMsgIds.size > MSG_ID_CACHE_MAX) {
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 (now - ts > MSG_ID_TTL_MS) {
563
- seenMsgIds.delete(id);
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.0";
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 = 15 * 60 * 1000; // 15 minutes
317
- const MSG_ID_CACHE_MAX = 5000;
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
- // Cleanup old entries if cache is too large
817
- if (seenMsgIds.size > MSG_ID_CACHE_MAX) {
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 (now - ts > MSG_ID_TTL_MS) {
820
- seenMsgIds.delete(id);
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.0";
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;
@@ -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.5",
5
+ "version": "0.6.6",
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.5",
3
+ "version": "0.6.6",
4
4
  "description": "Threema Gateway channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",