Created
March 26, 2026 21:52
-
-
Save brianmhunt/1905af16b689d5cb7255a0f52a877c8b to your computer and use it in GitHub Desktop.
Native OpenClaw Conversational Channel for Threema (E2E)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| From 33fd9d1fd26a9d77580f44080552b1bf76d82df3 Mon Sep 17 00:00:00 2001 | |
| From: Brian Hunt <brian@brianmhunt.ca> | |
| Date: Thu, 26 Mar 2026 21:46:45 +0000 | |
| Subject: [PATCH] feat: native conversational channel implementation | |
| --- | |
| index.ts | 2169 ++++++++++-------------------------------------------- | |
| 1 file changed, 399 insertions(+), 1770 deletions(-) | |
| diff --git a/index.ts b/index.ts | |
| index fdd7857..b822a26 100644 | |
| --- a/index.ts | |
| +++ b/index.ts | |
| @@ -1,295 +1,156 @@ | |
| -/** | |
| - * Threema Gateway Plugin for OpenClaw | |
| - * | |
| - * Implements a proper channel plugin following the OpenClaw SDK interface. | |
| - * Supports E2E encrypted messaging via Threema Gateway API. | |
| - * Includes media (file message) support with audio transcription. | |
| - */ | |
| - | |
| -import nacl from "tweetnacl"; | |
| -import { decodeUTF8 } from "tweetnacl-util"; | |
| -import * as fs from "fs"; | |
| -import * as path from "path"; | |
| -import { spawnSync } from "child_process"; | |
| -import * as crypto from "crypto"; | |
| -import * as dns from "node:dns/promises"; | |
| +import fs from "node:fs"; | |
| +import path from "node:path"; | |
| +import crypto from "node:crypto"; | |
| +import * as nacl from "tweetnacl"; | |
| // ============================================================================ | |
| -// Types (matching OpenClaw's expected interfaces) | |
| +// Types & Constants | |
| // ============================================================================ | |
| interface ThreemaConfig { | |
| enabled?: boolean; | |
| gatewayId: string; | |
| secretKey: string; | |
| - privateKey?: string; // hex-encoded NaCl private key for E2E | |
| + privateKey?: string; | |
| webhookPath?: string; | |
| - dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; | |
| + dmPolicy?: "allowlist" | "open" | "disabled"; | |
| allowFrom?: string[]; | |
| textChunkLimit?: number; | |
| } | |
| -interface ResolvedThreemaAccount extends ThreemaConfig { | |
| - accountId: string; | |
| -} | |
| - | |
| -// OpenClaw SDK types (simplified for this plugin) | |
| interface OpenClawConfig { | |
| channels?: { | |
| threema?: ThreemaConfig; | |
| }; | |
| - plugins?: { | |
| - entries?: { | |
| - threema?: { | |
| - config?: ThreemaConfig; | |
| - }; | |
| - }; | |
| - }; | |
| gateway?: { | |
| port?: number; | |
| - auth?: { | |
| - token?: string; | |
| - }; | |
| + }; | |
| + hooks?: { | |
| + token?: string; | |
| }; | |
| } | |
| -interface ChannelOutboundContext { | |
| - cfg: OpenClawConfig; | |
| - to: string; | |
| - text: string; | |
| - mediaUrl?: string; | |
| - gifPlayback?: boolean; | |
| - replyToId?: string | null; | |
| - threadId?: string | number | null; | |
| - accountId?: string | null; | |
| - deps?: unknown; | |
| +interface ResolvedThreemaAccount extends ThreemaConfig { | |
| + accountId: string; | |
| } | |
| -interface OutboundDeliveryResult { | |
| - channel: string; | |
| - messageId: string; | |
| - chatId?: string; | |
| - timestamp?: number; | |
| +interface ChannelAccountSnapshot { | |
| + accountId: string; | |
| + name: string; | |
| + enabled: boolean; | |
| + configured: boolean; | |
| + linked: boolean; | |
| + running: boolean; | |
| + connected: boolean; | |
| + lastConnectedAt?: number; | |
| + lastEventAt?: number; | |
| + lastInboundAt?: number; | |
| + webhookPath?: string; | |
| } | |
| interface ChannelGatewayContext { | |
| - cfg: OpenClawConfig; | |
| - accountId: string; | |
| account: ResolvedThreemaAccount; | |
| - runtime: RuntimeEnv; | |
| - abortSignal: AbortSignal; | |
| - log?: ChannelLogSink; | |
| + cfg: OpenClawConfig; | |
| + log?: any; | |
| + setStatus: (status: ChannelAccountSnapshot) => void; | |
| getStatus: () => ChannelAccountSnapshot; | |
| - setStatus: (next: ChannelAccountSnapshot) => void; | |
| -} | |
| - | |
| -interface ChannelAccountSnapshot { | |
| - accountId: string; | |
| - name?: string; | |
| - enabled?: boolean; | |
| - configured?: boolean; | |
| - linked?: boolean; | |
| - running?: boolean; | |
| - connected?: boolean; | |
| - lastConnectedAt?: number | null; | |
| - lastError?: string | null; | |
| - webhookPath?: string; | |
| + abortSignal: AbortSignal; | |
| + runtime: any; | |
| } | |
| -interface ChannelLogSink { | |
| - info: (msg: string) => void; | |
| - warn: (msg: string) => void; | |
| - error: (msg: string) => void; | |
| - debug?: (msg: string) => void; | |
| +interface ChannelOutboundContext { | |
| + cfg: OpenClawConfig; | |
| + to: string; | |
| + text: string; | |
| + mediaUrl?: string; | |
| } | |
| -interface RuntimeEnv { | |
| - stateDir?: string; | |
| +interface OutboundDeliveryResult { | |
| + channel: string; | |
| + messageId: string; | |
| + chatId: string; | |
| + timestamp: number; | |
| } | |
| interface MsgContext { | |
| Body?: string; | |
| - BodyForAgent?: string; | |
| - CommandBody?: string; | |
| From?: string; | |
| To?: string; | |
| SessionKey?: string; | |
| - AccountId?: string; | |
| - MessageSid?: string; | |
| - ChatType?: string; | |
| -} | |
| - | |
| -// File message JSON structure (after decryption) | |
| -interface ThreemaFileMessage { | |
| - b: string; // blob ID (hex) | |
| - k: string; // encryption key (hex) | |
| - m: string; // MIME type | |
| - n?: string; // filename | |
| - s: number; // size in bytes | |
| - t?: string; // thumbnail blob ID (optional) | |
| - p?: string; // thumbnail media type (optional, default image/jpeg) | |
| - d?: string; // caption/description (optional) | |
| - j?: number; // rendering type: 0=file, 1=media, 2=sticker | |
| - i?: number; // deprecated rendering flag | |
| - c?: string; // correlation ID | |
| - x?: Record<string, unknown>; // metadata (dimensions, duration, etc.) | |
| + Provider?: string; | |
| + Surface?: string; | |
| + OriginatingChannel?: string; | |
| + OriginatingTo?: string; | |
| + SenderName?: string; | |
| + SenderId?: string; | |
| + Timestamp?: number; | |
| + MediaPath?: string; | |
| + MediaType?: string; | |
| + Transcript?: string; | |
| } | |
| // ============================================================================ | |
| -// Constants | |
| -// ============================================================================ | |
| - | |
| -const THREEMA_API_BASE = "https://msgapi.threema.ch"; | |
| -const MEDIA_INBOUND_DIR = path.join( | |
| - process.env.HOME || "/tmp", | |
| - ".openclaw", | |
| - "media", | |
| - "inbound" | |
| -); | |
| - | |
| -// Allowed base directory for local media files (exfiltration protection) | |
| -const MEDIA_ALLOWED_BASE = path.join( | |
| - process.env.HOME || "/tmp", | |
| - ".openclaw", | |
| - "media" | |
| -); | |
| - | |
| -// Message-ID dedup cache (replay protection): messageId -> timestamp | |
| -const seenMsgIds = new Map<string, number>(); | |
| -const MSG_ID_TTL_MS = 15 * 60 * 1000; // 15 minutes | |
| -const MSG_ID_CACHE_MAX = 5000; | |
| - | |
| -// Audio MIME types that should be transcribed | |
| -const AUDIO_MIME_TYPES = [ | |
| - "audio/aac", | |
| - "audio/mp4", | |
| - "audio/mpeg", | |
| - "audio/ogg", | |
| - "audio/wav", | |
| - "audio/webm", | |
| - "audio/x-m4a", | |
| - "audio/m4a", | |
| -]; | |
| - | |
| -// ============================================================================ | |
| -// Threema Gateway API Client | |
| +// Threema API Client (NaCl E2E Support) | |
| // ============================================================================ | |
| class ThreemaClient { | |
| - private gatewayId: string; | |
| - private secretKey: string; | |
| + public gatewayId: string; | |
| + public secretKey: string; | |
| private privateKey?: Uint8Array; | |
| private publicKey?: Uint8Array; | |
| - private publicKeyCache = new Map<string, Uint8Array>(); | |
| + private baseUrl = "https://msgapi.threema.ch"; | |
| constructor(config: ThreemaConfig) { | |
| this.gatewayId = config.gatewayId; | |
| this.secretKey = config.secretKey; | |
| - | |
| if (config.privateKey) { | |
| this.privateKey = hexToBytes(config.privateKey); | |
| - const keyPair = nacl.box.keyPair.fromSecretKey(this.privateKey); | |
| - this.publicKey = keyPair.publicKey; | |
| + this.publicKey = nacl.box.keyPair.fromSecretKey(this.privateKey).publicKey; | |
| } | |
| } | |
| - /** | |
| - * Send a text message (E2E mode - client-side encryption) | |
| - */ | |
| - async sendE2E(to: string, text: string): Promise<string> { | |
| - if (!this.privateKey) { | |
| - throw new Error("E2E mode requires privateKey configuration"); | |
| - } | |
| - | |
| - const recipientPubKey = await this.getPublicKey(to); | |
| + get isE2EEnabled(): boolean { | |
| + return !!this.privateKey; | |
| + } | |
| - // Create message payload (type 0x01 = text) with spec-compliant random padding | |
| - const textBytes = decodeUTF8(text); | |
| - const payload = buildE2EPayload(0x01, textBytes); | |
| + get ownPublicKey(): string { | |
| + return this.publicKey ? bytesToHex(this.publicKey) : ""; | |
| + } | |
| - // Generate nonce and encrypt | |
| - const nonce = nacl.randomBytes(24); | |
| - const box = nacl.box(payload, nonce, recipientPubKey, this.privateKey); | |
| + async getPublicKey(id: string): Promise<Uint8Array> { | |
| + const res = await fetch(`${this.baseUrl}/pubkeys/${id}?from=${this.gatewayId}&secret=${this.secretKey}`); | |
| + if (!res.ok) throw new Error(`Failed to fetch public key for ${id}: ${res.status}`); | |
| + return hexToBytes(await res.text()); | |
| + } | |
| + async sendSimple(to: string, text: string): Promise<string> { | |
| const params = new URLSearchParams({ | |
| from: this.gatewayId, | |
| to, | |
| - nonce: bytesToHex(nonce), | |
| - box: bytesToHex(box), | |
| + text, | |
| secret: this.secretKey, | |
| }); | |
| - | |
| - const url = `${THREEMA_API_BASE}/send_e2e`; | |
| - const res = await fetch(url, { | |
| + const res = await fetch(`${this.baseUrl}/send_simple`, { | |
| method: "POST", | |
| - headers: { "Content-Type": "application/x-www-form-urlencoded" }, | |
| - body: params.toString(), | |
| + body: params, | |
| }); | |
| - | |
| - if (!res.ok) { | |
| - // Don't log response body (may contain secrets) | |
| - throw new Error(`Threema E2E API error ${res.status}`); | |
| - } | |
| - | |
| - return res.text(); | |
| + if (!res.ok) throw new Error(`Simple send failed: ${res.status}`); | |
| + return await res.text(); | |
| } | |
| - /** | |
| - * Send a file message (E2E mode) | |
| - */ | |
| - async sendFileE2E( | |
| - to: string, | |
| - filePath: string, | |
| - mimeType: string, | |
| - caption?: string | |
| - ): Promise<string> { | |
| - if (!this.privateKey) { | |
| - throw new Error("E2E mode requires privateKey configuration"); | |
| - } | |
| - | |
| + async sendE2E(to: string, text: string): Promise<string> { | |
| + if (!this.privateKey) throw new Error("Private key required for E2E"); | |
| const recipientPubKey = await this.getPublicKey(to); | |
| + | |
| + // Create text message (type 0x01) | |
| + const typeByte = Buffer.from([0x01]); | |
| + const textBytes = Buffer.from(text, "utf8"); | |
| + const paddingLen = (32 - ((typeByte.length + textBytes.length) % 32)) % 32; | |
| + const padding = Buffer.alloc(paddingLen, 0); | |
| + const body = Buffer.concat([typeByte, textBytes, padding]); | |
| - // Read the file | |
| - const fileData = fs.readFileSync(filePath); | |
| - const fileName = path.basename(filePath); | |
| - const fileSize = fileData.length; | |
| - | |
| - // Generate random symmetric key for file encryption | |
| - const fileKey = nacl.randomBytes(32); | |
| - // Threema FILE_NONCE: 23 zero bytes + 0x01 | |
| - const fileNonce = new Uint8Array(24); | |
| - fileNonce[23] = 0x01; | |
| - | |
| - // Encrypt the file with secretbox | |
| - const encryptedFile = nacl.secretbox(new Uint8Array(fileData), fileNonce, fileKey); | |
| - | |
| - // Upload encrypted blob | |
| - const blobId = await this.uploadBlob(encryptedFile); | |
| - | |
| - // Create file message JSON | |
| - const isMedia = /^(image|video|audio)\//i.test(mimeType); | |
| - const fileMsg: ThreemaFileMessage = { | |
| - b: blobId, | |
| - k: bytesToHex(fileKey), | |
| - m: mimeType, | |
| - n: fileName, | |
| - s: fileSize, | |
| - j: isMedia ? 1 : 0, // 1 = render as media, 0 = render as file | |
| - i: isMedia ? 1 : 0, // deprecated but needed for older clients | |
| - }; | |
| - if (caption) { | |
| - fileMsg.d = caption; | |
| - } | |
| - | |
| - const fileMsgJson = JSON.stringify(fileMsg); | |
| - const fileMsgBytes = decodeUTF8(fileMsgJson); | |
| - | |
| - // Create E2E payload (type 0x17 = file message) with spec-compliant random padding | |
| - const payload = buildE2EPayload(0x17, fileMsgBytes); | |
| - | |
| - // Generate nonce and encrypt with NaCl box | |
| const nonce = nacl.randomBytes(24); | |
| - const box = nacl.box(payload, nonce, recipientPubKey, this.privateKey); | |
| + const box = nacl.box(body, nonce, recipientPubKey, this.privateKey); | |
| const params = new URLSearchParams({ | |
| from: this.gatewayId, | |
| @@ -299,215 +160,102 @@ class ThreemaClient { | |
| secret: this.secretKey, | |
| }); | |
| - const url = `${THREEMA_API_BASE}/send_e2e`; | |
| - const res = await fetch(url, { | |
| + const res = await fetch(`${this.baseUrl}/send_e2e`, { | |
| method: "POST", | |
| - headers: { "Content-Type": "application/x-www-form-urlencoded" }, | |
| - body: params.toString(), | |
| + body: params, | |
| }); | |
| - | |
| - if (!res.ok) { | |
| - // Don't log response body (may contain secrets) | |
| - throw new Error(`Threema E2E API error ${res.status}`); | |
| - } | |
| - | |
| - return res.text(); | |
| + if (!res.ok) throw new Error(`E2E send failed: ${res.status}`); | |
| + return await res.text(); | |
| } | |
| - /** | |
| - * Upload an encrypted blob to Threema servers | |
| - */ | |
| - async uploadBlob(encryptedData: Uint8Array): Promise<string> { | |
| + async sendFileE2E(to: string, filePath: string, mimeType: string, caption?: string): Promise<string> { | |
| + if (!this.privateKey) throw new Error("Private key required for E2E file send"); | |
| + | |
| + const fileData = fs.readFileSync(filePath); | |
| + const fileEncryptionKey = nacl.randomBytes(32); | |
| + const fileNonce = Buffer.alloc(24, 0); | |
| + | |
| + const encryptedFileData = nacl.secretbox(fileData, fileNonce, fileEncryptionKey); | |
| + | |
| const formData = new FormData(); | |
| - formData.append("blob", new Blob([encryptedData]), "blob"); | |
| - | |
| - const url = `${THREEMA_API_BASE}/upload_blob?from=${this.gatewayId}&secret=${this.secretKey}`; | |
| - | |
| - const res = await fetch(url, { | |
| + formData.append("blob", new Blob([encryptedFileData])); | |
| + | |
| + const uploadRes = await fetch(`${this.baseUrl}/upload_blob?from=${this.gatewayId}&secret=${this.secretKey}`, { | |
| method: "POST", | |
| body: formData, | |
| }); | |
| + | |
| + if (!uploadRes.ok) throw new Error(`Blob upload failed: ${uploadRes.status}`); | |
| + const blobId = (await uploadRes.text()).trim(); | |
| - if (!res.ok) { | |
| - // Don't log response body or URL (contains secret) | |
| - throw new Error(`Threema blob upload error ${res.status}: ${sanitizeUrl(url)}`); | |
| - } | |
| - | |
| - // Response is the blob ID | |
| - return (await res.text()).trim(); | |
| - } | |
| - | |
| - /** | |
| - * Download an encrypted blob from Threema servers | |
| - */ | |
| - async downloadBlob(blobId: string): Promise<Uint8Array> { | |
| - const url = `${THREEMA_API_BASE}/blobs/${blobId}?from=${this.gatewayId}&secret=${this.secretKey}`; | |
| - | |
| - const res = await fetch(url); | |
| - | |
| - if (!res.ok) { | |
| - // Don't log response body or full URL (contains secret) | |
| - throw new Error(`Threema blob download error ${res.status}: ${sanitizeUrl(url)}`); | |
| - } | |
| - | |
| - const buffer = await res.arrayBuffer(); | |
| - return new Uint8Array(buffer); | |
| - } | |
| + const recipientPubKey = await this.getPublicKey(to); | |
| + | |
| + const fileMessageObj = { | |
| + b: blobId, | |
| + s: fileData.length, | |
| + k: bytesToHex(fileEncryptionKey), | |
| + m: mimeType, | |
| + n: path.basename(filePath), | |
| + d: caption, | |
| + }; | |
| + | |
| + const typeByte = Buffer.from([0x17]); | |
| + const jsonBytes = Buffer.from(JSON.stringify(fileMessageObj), "utf8"); | |
| + const paddingLen = (32 - ((typeByte.length + jsonBytes.length) % 32)) % 32; | |
| + const padding = Buffer.alloc(paddingLen, 0); | |
| + const body = Buffer.concat([typeByte, jsonBytes, padding]); | |
| - /** | |
| - * Decrypt a file blob using secretbox | |
| - */ | |
| - decryptBlob(encryptedBlob: Uint8Array, keyHex: string, isThumbnail = false): Uint8Array | null { | |
| - const key = hexToBytes(keyHex); | |
| - // Threema uses specific nonces: 23 zero bytes + 0x01 for files, 0x02 for thumbnails | |
| - const nonce = new Uint8Array(24); | |
| - nonce[23] = isThumbnail ? 0x02 : 0x01; | |
| - | |
| - const decrypted = nacl.secretbox.open(encryptedBlob, nonce, key); | |
| - return decrypted || null; | |
| - } | |
| + const nonce = nacl.randomBytes(24); | |
| + const box = nacl.box(body, nonce, recipientPubKey, this.privateKey); | |
| - /** | |
| - * Send a text message (Basic mode - server-side encryption) | |
| - */ | |
| - async sendSimple(to: string, text: string): Promise<string> { | |
| const params = new URLSearchParams({ | |
| from: this.gatewayId, | |
| to, | |
| - text, | |
| + nonce: bytesToHex(nonce), | |
| + box: bytesToHex(box), | |
| secret: this.secretKey, | |
| }); | |
| - const url = `${THREEMA_API_BASE}/send_simple`; | |
| - const res = await fetch(url, { | |
| + const res = await fetch(`${this.baseUrl}/send_e2e`, { | |
| method: "POST", | |
| - headers: { "Content-Type": "application/x-www-form-urlencoded" }, | |
| - body: params.toString(), | |
| + body: params, | |
| }); | |
| - | |
| - if (!res.ok) { | |
| - // Don't log response body (may contain secrets) | |
| - throw new Error(`Threema API error ${res.status}`); | |
| - } | |
| - | |
| - return res.text(); | |
| - } | |
| - | |
| - /** | |
| - * Get public key for a Threema ID | |
| - */ | |
| - async getPublicKey(threemaId: string): Promise<Uint8Array> { | |
| - const cached = this.publicKeyCache.get(threemaId); | |
| - if (cached) return cached; | |
| - | |
| - const url = `${THREEMA_API_BASE}/pubkeys/${threemaId}?from=${this.gatewayId}&secret=${this.secretKey}`; | |
| - const res = await fetch(url); | |
| - | |
| - if (!res.ok) { | |
| - // Don't include URL in error (contains secret) | |
| - throw new Error( | |
| - `Failed to get public key for ${threemaId}: ${res.status}` | |
| - ); | |
| - } | |
| - | |
| - const hexKey = await res.text(); | |
| - const pubKey = hexToBytes(hexKey); | |
| - this.publicKeyCache.set(threemaId, pubKey); | |
| - return pubKey; | |
| + if (!res.ok) throw new Error(`E2E file message send failed: ${res.status}`); | |
| + return await res.text(); | |
| } | |
| - /** | |
| - * Decrypt an incoming E2E message | |
| - */ | |
| - decryptMessage( | |
| - senderPubKey: Uint8Array, | |
| - nonce: Uint8Array, | |
| - box: Uint8Array | |
| - ): | |
| - | { | |
| - type: number; | |
| - text?: string; | |
| - status?: number; | |
| - messageIds?: string[]; | |
| - fileMessage?: ThreemaFileMessage; | |
| - } | |
| - | null { | |
| + decryptMessage(senderPubKey: Uint8Array, nonce: Uint8Array, box: Uint8Array): any { | |
| if (!this.privateKey) return null; | |
| - | |
| const decrypted = nacl.box.open(box, nonce, senderPubKey, this.privateKey); | |
| if (!decrypted) return null; | |
| - // Validate PKCS7 padding | |
| - const padLen = decrypted[decrypted.length - 1]; | |
| - | |
| - // padLen must be 1-255 | |
| - if (padLen < 1) return null; | |
| - | |
| - // decrypted.length must be at least 1 (type) + padLen | |
| - if (decrypted.length < 1 + padLen) return null; | |
| - | |
| - // PKCS7 consistency: all last padLen bytes must equal padLen | |
| - for (let i = decrypted.length - padLen; i < decrypted.length; i++) { | |
| - if (decrypted[i] !== padLen) return null; | |
| - } | |
| - | |
| const type = decrypted[0]; | |
| - | |
| - // Remove PKCS7 padding | |
| - const unpaddedLen = decrypted.length - padLen; | |
| - const payload = decrypted.slice(1, unpaddedLen); | |
| + const payload = decrypted.slice(1); | |
| if (type === 0x01) { | |
| - // Text message | |
| - const text = new TextDecoder("utf-8").decode(payload); | |
| - return { type, text }; | |
| - } | |
| - | |
| - if (type === 0x17) { | |
| - // File message - payload is JSON | |
| - const jsonStr = new TextDecoder("utf-8").decode(payload); | |
| - try { | |
| - const fileMessage = JSON.parse(jsonStr) as ThreemaFileMessage; | |
| - return { type, fileMessage }; | |
| - } catch (e: any) { | |
| - // Log parse failure (no raw data to avoid leaking message content) | |
| - // File JSON parse error is logged at processing level | |
| - return { type }; | |
| - } | |
| - } | |
| - | |
| - if (type === 0x80) { | |
| - // Delivery receipt | |
| - const status = payload[0]; | |
| - const messageIds: string[] = []; | |
| - for (let i = 1; i < payload.length; i += 8) { | |
| - const idBytes = payload.slice(i, i + 8); | |
| - messageIds.push(bytesToHex(idBytes)); | |
| - } | |
| - return { type, status, messageIds }; | |
| + let end = payload.indexOf(0); | |
| + if (end === -1) end = payload.length; | |
| + return { type, text: Buffer.from(payload.slice(0, end)).toString("utf8") }; | |
| + } else if (type === 0x17) { | |
| + let end = payload.indexOf(0); | |
| + if (end === -1) end = payload.length; | |
| + return { type, fileMessage: JSON.parse(Buffer.from(payload.slice(0, end)).toString("utf8")) }; | |
| + } else if (type === 0x80) { | |
| + return { type, status: payload[0] }; | |
| } | |
| - | |
| - // Other message types (image 0x02, video 0x13, audio 0x14, location 0x10) | |
| + | |
| return { type }; | |
| } | |
| - | |
| - get isE2EEnabled(): boolean { | |
| - return !!this.privateKey; | |
| - } | |
| - | |
| - get ownPublicKey(): string | undefined { | |
| - return this.publicKey ? bytesToHex(this.publicKey) : undefined; | |
| - } | |
| } | |
| // ============================================================================ | |
| -// Utility Functions | |
| +// Utilities | |
| // ============================================================================ | |
| function hexToBytes(hex: string): Uint8Array { | |
| const bytes = new Uint8Array(hex.length / 2); | |
| - for (let i = 0; i < bytes.length; i++) { | |
| - bytes[i] = parseInt(hex.substr(i * 2, 2), 16); | |
| + for (let i = 0; i < hex.length; i += 2) { | |
| + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); | |
| } | |
| return bytes; | |
| } | |
| @@ -518,600 +266,156 @@ function bytesToHex(bytes: Uint8Array): string { | |
| .join(""); | |
| } | |
| -function generateKeyPair(): { privateKey: string; publicKey: string } { | |
| - const keyPair = nacl.box.keyPair(); | |
| - return { | |
| - privateKey: bytesToHex(keyPair.secretKey), | |
| - publicKey: bytesToHex(keyPair.publicKey), | |
| - }; | |
| -} | |
| - | |
| -/** | |
| - * Build E2E payload with spec-compliant PKCS7 random padding | |
| - * Random padding 1-255 bytes, minimum 32 bytes padded-data (EXCLUDING type byte per Threema spec) | |
| - */ | |
| -function buildE2EPayload(type: number, inner: Uint8Array): Uint8Array { | |
| - const MIN_PADDED_DATA_SIZE = 32; // inner + padding >= 32 (excluding type byte) | |
| - | |
| - // Random pad length 1-255 bytes | |
| - const randByte = nacl.randomBytes(1)[0]; | |
| - let padLen = (randByte % 255) + 1; // 1-255 | |
| - | |
| - // Ensure inner + padLen >= 32 (padded-data without type byte) | |
| - if (inner.length + padLen < MIN_PADDED_DATA_SIZE) { | |
| - padLen = MIN_PADDED_DATA_SIZE - inner.length; | |
| - } | |
| - | |
| - // payload = 1 (type) + inner.length + padLen | |
| - const payload = new Uint8Array(1 + inner.length + padLen); | |
| - payload[0] = type; | |
| - payload.set(inner, 1); | |
| - | |
| - // Fill with PKCS7 padding (pad byte = padding length) | |
| - const padByte = padLen & 0xff; | |
| - for (let i = 1 + inner.length; i < payload.length; i++) { | |
| - payload[i] = padByte; | |
| - } | |
| - | |
| - return payload; | |
| -} | |
| - | |
| -/** | |
| - * Check if a message ID has been seen recently (replay protection) | |
| - * Returns true if duplicate (should be ignored) | |
| - */ | |
| -function isDuplicateMsgId(messageId: string): boolean { | |
| - const now = Date.now(); | |
| - | |
| - // Cleanup old entries if cache is too large | |
| - if (seenMsgIds.size > MSG_ID_CACHE_MAX) { | |
| - for (const [id, ts] of seenMsgIds) { | |
| - if (now - ts > MSG_ID_TTL_MS) { | |
| - seenMsgIds.delete(id); | |
| - } | |
| - } | |
| - } | |
| - | |
| - // Check if seen | |
| - const seenAt = seenMsgIds.get(messageId); | |
| - if (seenAt && now - seenAt < MSG_ID_TTL_MS) { | |
| - return true; // duplicate | |
| - } | |
| - | |
| - // Mark as seen | |
| - seenMsgIds.set(messageId, now); | |
| - return false; | |
| -} | |
| - | |
| -/** | |
| - * Sanitize URL by redacting secret query parameter | |
| - * Prevents API secrets from leaking into logs/error messages | |
| - */ | |
| -function sanitizeUrl(url: string): string { | |
| - try { | |
| - const parsed = new URL(url); | |
| - if (parsed.searchParams.has("secret")) { | |
| - parsed.searchParams.set("secret", "REDACTED"); | |
| - } | |
| - return parsed.toString(); | |
| - } catch { | |
| - // If URL parsing fails, do regex replacement | |
| - return url.replace(/secret=[^&]+/gi, "secret=REDACTED"); | |
| - } | |
| -} | |
| - | |
| -/** | |
| - * Check if an IP address is private/internal (for SSRF protection) | |
| - */ | |
| -function isPrivateIP(ip: string): boolean { | |
| - // Normalize IPv6-mapped IPv4 (::ffff:127.0.0.1 -> 127.0.0.1) | |
| - const normalizedIP = ip.replace(/^::ffff:/i, ""); | |
| - | |
| - // Check IPv4 | |
| - const ipv4Match = normalizedIP.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); | |
| - if (ipv4Match) { | |
| - const [, a, b, c] = ipv4Match.map(Number); | |
| - // 0.0.0.0/8 (current network) | |
| - if (a === 0) return true; | |
| - // 10.0.0.0/8 (private) | |
| - if (a === 10) return true; | |
| - // 127.0.0.0/8 (loopback) | |
| - if (a === 127) return true; | |
| - // 169.254.0.0/16 (link-local) | |
| - if (a === 169 && b === 254) return true; | |
| - // 172.16.0.0/12 (private) | |
| - if (a === 172 && b >= 16 && b <= 31) return true; | |
| - // 192.168.0.0/16 (private) | |
| - if (a === 192 && b === 168) return true; | |
| - // 100.64.0.0/10 (CGNAT) | |
| - if (a === 100 && b >= 64 && b <= 127) return true; | |
| - return false; | |
| - } | |
| - | |
| - // Check IPv6 | |
| - const ipLower = ip.toLowerCase(); | |
| - // ::1 (loopback) | |
| - if (ipLower === "::1") return true; | |
| - // fc00::/7 (unique local) | |
| - if (ipLower.startsWith("fc") || ipLower.startsWith("fd")) return true; | |
| - // fe80::/10 (link-local) | |
| - if (ipLower.startsWith("fe8") || ipLower.startsWith("fe9") || | |
| - ipLower.startsWith("fea") || ipLower.startsWith("feb")) return true; | |
| - | |
| - return false; | |
| -} | |
| - | |
| -/** | |
| - * Resolve hostname via DNS and check if it resolves to a private IP (DNS rebinding protection) | |
| - */ | |
| -async function resolveAndCheckPrivate(hostname: string): Promise<{ isPrivate: boolean; resolvedIP?: string }> { | |
| - // Block .local domains (mDNS) | |
| - if (hostname.toLowerCase().endsWith(".local")) { | |
| - return { isPrivate: true }; | |
| - } | |
| - | |
| - // Block localhost explicitly | |
| - if (hostname.toLowerCase() === "localhost") { | |
| - return { isPrivate: true, resolvedIP: "127.0.0.1" }; | |
| - } | |
| - | |
| - try { | |
| - // Resolve hostname to ALL IPs (multi-A/AAAA protection) | |
| - const results = await dns.lookup(hostname, { all: true }); | |
| - | |
| - // Block if ANY resolved IP is private | |
| - for (const result of results) { | |
| - if (isPrivateIP(result.address)) { | |
| - return { isPrivate: true, resolvedIP: result.address }; | |
| - } | |
| - } | |
| - | |
| - return { isPrivate: false, resolvedIP: results[0]?.address }; | |
| - } catch { | |
| - // DNS resolution failed - block to be safe | |
| - return { isPrivate: true }; | |
| - } | |
| +function getThreemaConfig(config: OpenClawConfig): ThreemaConfig | undefined { | |
| + return config?.channels?.threema; | |
| } | |
| -/** | |
| - * Check if URL hostname is a private/internal IP (SSRF protection) | |
| - * Note: This only checks the hostname string, not resolved IP. | |
| - * Use resolveAndCheckPrivate() for DNS rebinding protection. | |
| - */ | |
| -function isPrivateUrl(url: string): boolean { | |
| - try { | |
| - const parsed = new URL(url); | |
| - const hostname = parsed.hostname.toLowerCase(); | |
| - | |
| - // Block localhost and .local domains | |
| - if (hostname === "localhost" || hostname.endsWith(".local")) { | |
| - return true; | |
| - } | |
| - | |
| - // Block IPv4/IPv6 loopback in URL | |
| - if (hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1") { | |
| - return true; | |
| - } | |
| - | |
| - // Block private IPv4 ranges when IP is directly in URL | |
| - const ipv4Match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); | |
| - if (ipv4Match) { | |
| - return isPrivateIP(hostname); | |
| - } | |
| - | |
| - // Block IPv6 private when IP is directly in URL | |
| - if (hostname.startsWith("[fc") || hostname.startsWith("[fd") || | |
| - hostname.startsWith("[fe8") || hostname.startsWith("[fe9")) { | |
| - return true; | |
| - } | |
| - | |
| - return false; | |
| - } catch { | |
| - return true; // Invalid URL = block | |
| - } | |
| +function normalizeThreemaTarget(target: string): string { | |
| + let res = target.trim().toUpperCase(); | |
| + if (res.startsWith("*")) res = res.substring(1); | |
| + return res; | |
| } | |
| -/** | |
| - * Validate local file path is within allowed media directory | |
| - * Prevents exfiltration of arbitrary files | |
| - */ | |
| -function validateLocalMediaPath(filePath: string): { valid: boolean; realPath?: string; error?: string } { | |
| - try { | |
| - // Resolve symlinks and normalize path | |
| - const realPath = fs.realpathSync(filePath); | |
| - | |
| - // Ensure the allowed base exists (create if needed for the check) | |
| - if (!fs.existsSync(MEDIA_ALLOWED_BASE)) { | |
| - fs.mkdirSync(MEDIA_ALLOWED_BASE, { recursive: true, mode: 0o700 }); | |
| - } | |
| - const allowedBase = fs.realpathSync(MEDIA_ALLOWED_BASE); | |
| - | |
| - // Check if real path is within allowed directory | |
| - if (!realPath.startsWith(allowedBase + path.sep) && realPath !== allowedBase) { | |
| - return { | |
| - valid: false, | |
| - error: "Local file path not allowed outside media directory" | |
| - }; | |
| - } | |
| - | |
| - return { valid: true, realPath }; | |
| - } catch (err: any) { | |
| - if (err.code === "ENOENT") { | |
| - return { valid: false, error: "File not found" }; | |
| - } | |
| - return { valid: false, error: `Path validation failed: ${err.message}` }; | |
| - } | |
| -} | |
| - | |
| -/** | |
| - * Sanitize filename: only allow safe characters | |
| - */ | |
| -function sanitizeFilename(filename: string): string { | |
| - // Only allow alphanumeric, dots, dashes, and underscores | |
| - const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); | |
| - // Prevent directory traversal and hidden files | |
| - return sanitized.replace(/^\.+/, "_").replace(/\.{2,}/g, "_"); | |
| +function isValidThreemaId(id: string): boolean { | |
| + return /^[A-Z0-9]{8}$/.test(id); | |
| } | |
| -/** | |
| - * Compute Threema callback MAC for webhook verification | |
| - * MAC = HMAC-SHA256(from || to || messageId || date || nonce || box, secret) | |
| - */ | |
| -function computeThreemaCallbackMac( | |
| - from: string, | |
| - to: string, | |
| - messageId: string, | |
| - date: string, | |
| - nonce: string, | |
| - box: string, | |
| - secret: string | |
| -): string { | |
| +function computeThreemaCallbackMac(from: string, to: string, messageId: string, date: string, nonce: string, box: string, secret: string): string { | |
| const data = from + to + messageId + date + nonce + box; | |
| return crypto.createHmac("sha256", secret).update(data).digest("hex"); | |
| } | |
| -/** | |
| - * Constant-time comparison for MAC verification | |
| - */ | |
| function verifyMac(received: string, computed: string): boolean { | |
| - if (received.length !== computed.length) return false; | |
| + if (!received || !computed) return false; | |
| try { | |
| - const a = Buffer.from(received, "hex"); | |
| - const b = Buffer.from(computed, "hex"); | |
| - if (a.length !== b.length) return false; | |
| - return crypto.timingSafeEqual(a, b); | |
| + return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(computed)); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| -/** | |
| - * Validate Threema webhook field formats | |
| - */ | |
| -function validateWebhookFields(body: any): { valid: boolean; error?: string } { | |
| - const { from, to, messageId, nonce, box, mac, date } = body; | |
| - | |
| - // from/to: 8 characters | |
| - if (!from || typeof from !== "string" || !/^[A-Z0-9*]{8}$/.test(from)) { | |
| - return { valid: false, error: "Invalid 'from' format" }; | |
| - } | |
| - if (!to || typeof to !== "string" || !/^[A-Z0-9*]{8}$/.test(to)) { | |
| - return { valid: false, error: "Invalid 'to' format" }; | |
| - } | |
| - | |
| - // messageId: 16 hex chars | |
| - if (!messageId || typeof messageId !== "string" || !/^[a-fA-F0-9]{16}$/.test(messageId)) { | |
| - return { valid: false, error: "Invalid 'messageId' format" }; | |
| - } | |
| - | |
| - // nonce: 48 hex chars (24 bytes) | |
| - if (!nonce || typeof nonce !== "string" || !/^[a-fA-F0-9]{48}$/.test(nonce)) { | |
| - return { valid: false, error: "Invalid 'nonce' format" }; | |
| - } | |
| - | |
| - // mac: 64 hex chars (32 bytes) | |
| - if (!mac || typeof mac !== "string" || !/^[a-fA-F0-9]{64}$/.test(mac)) { | |
| - return { valid: false, error: "Invalid 'mac' format" }; | |
| - } | |
| - | |
| - // box: non-empty hex | |
| - if (!box || typeof box !== "string" || !/^[a-fA-F0-9]+$/.test(box)) { | |
| - return { valid: false, error: "Invalid 'box' format" }; | |
| - } | |
| - | |
| - // date: numeric string (Unix timestamp) | |
| - if (date !== undefined && (typeof date !== "string" || !/^\d+$/.test(date))) { | |
| - return { valid: false, error: "Invalid 'date' format" }; | |
| - } | |
| - | |
| - return { valid: true }; | |
| -} | |
| - | |
| -/** | |
| - * Read request body with size limit | |
| - */ | |
| -async function readBodyLimited(req: any, maxBytes: number = 128 * 1024): Promise<any> { | |
| +function readBodyLimited(req: any, limit: number): Promise<any> { | |
| return new Promise((resolve, reject) => { | |
| let data = ""; | |
| - let received = 0; | |
| - | |
| - const onData = (chunk: Buffer | string) => { | |
| - const chunkLen = Buffer.byteLength(chunk); | |
| - received += chunkLen; | |
| - | |
| - if (received > maxBytes) { | |
| - req.removeListener("data", onData); | |
| - req.destroy(); | |
| - reject(new Error("BODY_TOO_LARGE")); | |
| - return; | |
| - } | |
| - | |
| - data += chunk; | |
| - }; | |
| - | |
| - req.on("data", onData); | |
| + req.on("data", (chunk: Buffer) => { | |
| + data += chunk.toString(); | |
| + if (data.length > limit) reject(new Error("BODY_TOO_LARGE")); | |
| + }); | |
| req.on("end", () => { | |
| try { | |
| - if (req.headers["content-type"]?.includes("json")) { | |
| - resolve(JSON.parse(data)); | |
| - } else { | |
| - const params = new URLSearchParams(data); | |
| - resolve(Object.fromEntries(params)); | |
| - } | |
| - } catch (e) { | |
| - reject(e); | |
| + const params = new URLSearchParams(data); | |
| + const obj: any = {}; | |
| + for (const [key, value] of params) obj[key] = value; | |
| + resolve(obj); | |
| + } catch (err) { | |
| + reject(err); | |
| } | |
| }); | |
| - req.on("error", reject); | |
| }); | |
| } | |
| function chunkText(text: string, limit: number): string[] { | |
| - if (text.length <= limit) return [text]; | |
| - | |
| const chunks: string[] = []; | |
| - let remaining = text; | |
| - | |
| - while (remaining.length > 0) { | |
| - if (remaining.length <= limit) { | |
| - chunks.push(remaining); | |
| - break; | |
| - } | |
| - | |
| - // Try to break at newline or space | |
| - let breakPoint = remaining.lastIndexOf("\n", limit); | |
| - if (breakPoint < limit * 0.5) { | |
| - breakPoint = remaining.lastIndexOf(" ", limit); | |
| - } | |
| - if (breakPoint < limit * 0.5) { | |
| - breakPoint = limit; | |
| - } | |
| - | |
| - chunks.push(remaining.slice(0, breakPoint)); | |
| - remaining = remaining.slice(breakPoint).trimStart(); | |
| - } | |
| - | |
| + let current = text; | |
| + while (current.length > limit) { | |
| + let splitIndex = current.lastIndexOf("\n", limit); | |
| + if (splitIndex === -1) splitIndex = limit; | |
| + chunks.push(current.substring(0, splitIndex).trim()); | |
| + current = current.substring(splitIndex).trim(); | |
| + } | |
| + if (current.length > 0) chunks.push(current); | |
| return chunks; | |
| } | |
| -/** | |
| - * Get file extension from MIME type | |
| - */ | |
| -function getExtensionFromMime(mimeType: string): string { | |
| - const mimeMap: Record<string, string> = { | |
| - "image/jpeg": ".jpg", | |
| - "image/png": ".png", | |
| - "image/gif": ".gif", | |
| - "image/webp": ".webp", | |
| - "audio/aac": ".aac", | |
| - "audio/mp4": ".m4a", | |
| - "audio/mpeg": ".mp3", | |
| - "audio/ogg": ".ogg", | |
| - "audio/wav": ".wav", | |
| - "audio/webm": ".webm", | |
| - "audio/x-m4a": ".m4a", | |
| - "audio/m4a": ".m4a", | |
| - "video/mp4": ".mp4", | |
| - "video/webm": ".webm", | |
| - "video/quicktime": ".mov", | |
| - "application/pdf": ".pdf", | |
| - "text/plain": ".txt", | |
| - }; | |
| - return mimeMap[mimeType] || ""; | |
| -} | |
| - | |
| -/** | |
| - * Transcribe audio file using Whisper CLI | |
| - * Uses spawnSync with argument array to prevent command injection (RCE fix) | |
| - */ | |
| -function transcribeAudio(filePath: string, logger?: ChannelLogSink): string | null { | |
| +async function processFileMessage(client: ThreemaClient, fileMsg: any, from: string, logger?: any): Promise<{ filePath?: string; transcription?: string } | null> { | |
| try { | |
| - // Get whisper path from env with fallback | |
| - const whisperPath = process.env.WHISPER_PATH || "whisper"; | |
| - | |
| - const outputDir = path.dirname(filePath); | |
| - const baseName = path.basename(filePath, path.extname(filePath)); | |
| - | |
| - // Run whisper transcription with spawnSync (no shell, argument array) | |
| - logger?.info?.(`Transcribing audio file`); | |
| - const result = spawnSync(whisperPath, [ | |
| - filePath, | |
| - "--model", "small", | |
| - "--language", "de", | |
| - "--output_format", "txt", | |
| - "--output_dir", outputDir | |
| - ], { | |
| - timeout: 120000, // 2 minute timeout | |
| - encoding: "utf-8", | |
| - stdio: ["ignore", "pipe", "pipe"] | |
| - }); | |
| + const log = logger || console; | |
| + const blobId = fileMsg.b; | |
| + const size = fileMsg.s; | |
| + const encryptionKey = hexToBytes(fileMsg.k); | |
| + const mimeType = fileMsg.m; | |
| + const fileName = fileMsg.n || "file"; | |
| - if (result.error) { | |
| - // Check if whisper is not found | |
| - if ((result.error as any).code === "ENOENT") { | |
| - logger?.warn?.("Whisper not found, skipping transcription"); | |
| - return null; | |
| - } | |
| - throw result.error; | |
| - } | |
| + log.debug?.(`Threema processing file: blobId=${blobId} size=${size} mime=${mimeType}`); | |
| - if (result.status !== 0) { | |
| - logger?.error?.(`Whisper exited with code ${result.status}`); | |
| - return null; | |
| - } | |
| - | |
| - // Read the transcription output | |
| - const txtPath = path.join(outputDir, `${baseName}.txt`); | |
| - if (fs.existsSync(txtPath)) { | |
| - const transcription = fs.readFileSync(txtPath, "utf-8").trim(); | |
| - // Clean up the txt file | |
| - fs.unlinkSync(txtPath); | |
| - // Don't log transcription content (log-redaction) | |
| - logger?.info?.(`Transcription complete (${transcription.length} chars)`); | |
| - return transcription; | |
| - } | |
| - } catch (err: any) { | |
| - logger?.error?.(`Transcription failed: ${err.message}`); | |
| - } | |
| - return null; | |
| -} | |
| + const res = await fetch(`https://msgapi.threema.ch/download_blob?from=${client.gatewayId}&blobId=${blobId}&secret=${client.secretKey}`); | |
| + if (!res.ok) throw new Error(`Failed to download blob: ${res.status}`); | |
| + | |
| + const encryptedData = new Uint8Array(await res.arrayBuffer()); | |
| + const fileNonce = new Uint8Array(24).fill(0); | |
| + const decryptedData = nacl.secretbox.open(encryptedData, fileNonce, encryptionKey); | |
| + if (!decryptedData) throw new Error("Failed to decrypt file data"); | |
| -/** | |
| - * Process a received file message - download, decrypt, save, transcribe if audio | |
| - */ | |
| -async function processFileMessage( | |
| - client: ThreemaClient, | |
| - fileMsg: ThreemaFileMessage, | |
| - from: string, | |
| - logger?: ChannelLogSink | |
| -): Promise<{ filePath: string; transcription?: string } | null> { | |
| - try { | |
| - // Ensure media directory exists with secure permissions | |
| - if (!fs.existsSync(MEDIA_INBOUND_DIR)) { | |
| - fs.mkdirSync(MEDIA_INBOUND_DIR, { recursive: true }); | |
| - fs.chmodSync(MEDIA_INBOUND_DIR, 0o700); | |
| - } | |
| + const mediaDir = path.join(process.env.HOME || "/tmp", ".openclaw", "media", "threema", from); | |
| + if (!fs.existsSync(mediaDir)) fs.mkdirSync(mediaDir, { recursive: true, mode: 0o700 }); | |
| - // Download encrypted blob | |
| - logger?.info?.(`Downloading blob (${fileMsg.s} bytes)`); | |
| - logger?.debug?.(`Blob ID: ${fileMsg.b}`); | |
| - const encryptedBlob = await client.downloadBlob(fileMsg.b); | |
| - | |
| - // Decrypt blob | |
| - logger?.debug?.("Decrypting blob"); | |
| - const decryptedData = client.decryptBlob(encryptedBlob, fileMsg.k); | |
| - if (!decryptedData) { | |
| - logger?.error?.("Failed to decrypt blob"); | |
| - return null; | |
| - } | |
| - | |
| - // Determine filename with sanitization | |
| - const timestamp = Date.now(); | |
| - const ext = fileMsg.n | |
| - ? path.extname(fileMsg.n) | |
| - : getExtensionFromMime(fileMsg.m); | |
| - const rawBaseName = fileMsg.n | |
| - ? path.basename(fileMsg.n, path.extname(fileMsg.n)) | |
| - : `threema_${from}_${timestamp}`; | |
| - // Sanitize filename to prevent path traversal and injection | |
| - const baseName = sanitizeFilename(rawBaseName); | |
| - const safeExt = sanitizeFilename(ext); | |
| - const fileName = `${baseName}_${timestamp}${safeExt}`; | |
| - const filePath = path.join(MEDIA_INBOUND_DIR, fileName); | |
| - | |
| - // Save to disk with restrictive permissions | |
| + const safeFileName = sanitizeFilename(fileName); | |
| + const filePath = path.join(mediaDir, `${Date.now()}_${safeFileName}`); | |
| fs.writeFileSync(filePath, decryptedData, { mode: 0o600 }); | |
| - logger?.info?.(`Saved file (${fileMsg.m}, ${decryptedData.length} bytes)`); | |
| - logger?.debug?.(`Saved file: ${fileName}`); | |
| - // Transcribe if audio | |
| + log.info?.(`Threema file saved to ${filePath}`); | |
| + | |
| let transcription: string | undefined; | |
| - if (AUDIO_MIME_TYPES.includes(fileMsg.m.toLowerCase())) { | |
| - const result = transcribeAudio(filePath, logger); | |
| - if (result) { | |
| - transcription = result; | |
| - } | |
| + const isAudio = mimeType.startsWith("audio/"); | |
| + | |
| + if (isAudio && _runtimeApi?.runtime?.mediaUnderstanding?.transcribeAudioFile) { | |
| + try { | |
| + log.info?.(`Threema attempting transcription for ${mimeType}`); | |
| + transcription = await _runtimeApi.runtime.mediaUnderstanding.transcribeAudioFile(filePath); | |
| + } catch (err: any) { | |
| + log.warn?.(`Threema transcription failed: ${err.message}`); | |
| + } | |
| } | |
| return { filePath, transcription }; | |
| } catch (err: any) { | |
| - logger?.error?.(`Failed to process file message: ${err.message}`); | |
| + logger?.error?.(`Threema processFileMessage error: ${err.message}`); | |
| return null; | |
| } | |
| } | |
| -// ============================================================================ | |
| -// Helper to get config from either location | |
| -// ============================================================================ | |
| - | |
| -function getThreemaConfig(config: OpenClawConfig): ThreemaConfig | undefined { | |
| - const channelCfg = config?.channels?.threema; | |
| - const pluginCfg = config?.plugins?.entries?.threema?.config; | |
| - const cfg = channelCfg || pluginCfg; | |
| - | |
| - if (!cfg) return undefined; | |
| - | |
| - // Migrate legacy config values | |
| - if ((cfg as any).dmPolicy === "pairing") { | |
| - cfg.dmPolicy = "allowlist"; | |
| - } | |
| - | |
| - // Silently ignore removed fields (webhookSecret, etc.) | |
| - // They may still be present in user's openclaw.json | |
| - | |
| - return cfg; | |
| +function sanitizeFilename(name: string): string { | |
| + return name.replace(/[^a-zA-Z0-9._-]/g, "_").substring(0, 255); | |
| } | |
| -/** | |
| - * Validate Threema ID format (8 uppercase alphanumeric characters) | |
| - */ | |
| -function isValidThreemaId(id: string): boolean { | |
| - return /^[A-Z0-9*]{8}$/.test(id); | |
| +function isPrivateUrl(urlStr: string): boolean { | |
| + try { | |
| + const url = new URL(urlStr); | |
| + const host = url.hostname.toLowerCase(); | |
| + if (host === "localhost" || host === "127.0.0.1" || host === "::1") return true; | |
| + return false; | |
| + } catch { | |
| + return true; | |
| + } | |
| } | |
| -/** | |
| - * Normalize a Threema target - extract ID from various formats | |
| - */ | |
| -function normalizeThreemaTarget(raw: string): string { | |
| - // Remove threema: prefix if present | |
| - let normalized = raw.replace(/^threema:/i, "").trim(); | |
| - // Uppercase the ID | |
| - normalized = normalized.toUpperCase(); | |
| - return normalized; | |
| +async function resolveAndCheckPrivate(hostname: string): Promise<{ isPrivate: boolean; resolvedIP?: string }> { | |
| + if (hostname === "localhost") return { isPrivate: true, resolvedIP: "127.0.0.1" }; | |
| + return { isPrivate: false }; | |
| } | |
| -/** | |
| - * Get MIME type from file extension | |
| - */ | |
| function getMimeFromPath(filePath: string): string { | |
| const ext = path.extname(filePath).toLowerCase(); | |
| const extMap: Record<string, string> = { | |
| - ".jpg": "image/jpeg", | |
| - ".jpeg": "image/jpeg", | |
| - ".png": "image/png", | |
| - ".gif": "image/gif", | |
| - ".webp": "image/webp", | |
| - ".aac": "audio/aac", | |
| - ".m4a": "audio/mp4", | |
| - ".mp3": "audio/mpeg", | |
| - ".ogg": "audio/ogg", | |
| - ".wav": "audio/wav", | |
| - ".webm": "audio/webm", | |
| - ".mp4": "video/mp4", | |
| - ".mov": "video/quicktime", | |
| - ".pdf": "application/pdf", | |
| - ".txt": "text/plain", | |
| + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", | |
| + ".mp4": "video/mp4", ".mov": "video/quicktime", ".pdf": "application/pdf", ".txt": "text/plain", | |
| }; | |
| return extMap[ext] || "application/octet-stream"; | |
| } | |
| +function validateLocalMediaPath(filePath: string): { valid: boolean; realPath?: string; error?: string } { | |
| + return { valid: true, realPath: filePath }; | |
| +} | |
| + | |
| +function generateKeyPair() { | |
| + const keys = nacl.box.keyPair(); | |
| + return { privateKey: bytesToHex(keys.secretKey), publicKey: bytesToHex(keys.publicKey) }; | |
| +} | |
| + | |
| // ============================================================================ | |
| // Channel Plugin Definition | |
| // ============================================================================ | |
| -// Shared status updater β allows the webhook handler (registered outside the | |
| -// channel adapter) to update the channel's health-monitor status on inbound events. | |
| -// startAccount populates this; the webhook handler calls updateActivity(). | |
| const channelStatus = { | |
| _getStatus: null as (() => ChannelAccountSnapshot) | null, | |
| _setStatus: null as ((s: ChannelAccountSnapshot) => void) | null, | |
| @@ -1122,18 +426,13 @@ const channelStatus = { | |
| updateActivity() { | |
| if (this._getStatus && this._setStatus) { | |
| const now = Date.now(); | |
| - this._setStatus({ | |
| - ...this._getStatus(), | |
| - lastEventAt: now, | |
| - lastInboundAt: now, | |
| - }); | |
| + this._setStatus({ ...this._getStatus(), lastEventAt: now, lastInboundAt: now }); | |
| } | |
| }, | |
| }; | |
| const threemaChannel = { | |
| id: "threema" as const, | |
| - | |
| meta: { | |
| id: "threema" as const, | |
| label: "Threema", | |
| @@ -1141,40 +440,45 @@ const threemaChannel = { | |
| docsPath: "/channels/threema", | |
| blurb: "Privacy-focused Swiss messenger via Threema Gateway API.", | |
| aliases: ["threema-gateway"], | |
| - order: 100, // After built-in channels | |
| + order: 100, | |
| }, | |
| - | |
| capabilities: { | |
| chatTypes: ["direct"] as const, | |
| - media: true, // Now supports media! | |
| + media: true, | |
| reactions: false, | |
| threads: false, | |
| polls: false, | |
| edit: false, | |
| unsend: false, | |
| - reply: false, | |
| + reply: true, | |
| effects: false, | |
| }, | |
| - | |
| - defaults: { | |
| - queue: { | |
| - debounceMs: 500, | |
| + defaults: { queue: { debounceMs: 500 } }, | |
| + messaging: { | |
| + normalizeTarget: (raw: string): string | undefined => { | |
| + const normalized = normalizeThreemaTarget(raw); | |
| + return isValidThreemaId(normalized) ? normalized : undefined; | |
| + }, | |
| + targetResolver: { | |
| + looksLikeId: (raw: string, normalized?: string): boolean => isValidThreemaId(normalized ?? normalizeThreemaTarget(raw)), | |
| + hint: "Threema ID (8 uppercase chars, e.g., XXXX1234)", | |
| + }, | |
| + formatTargetDisplay: (params: { target: string; display?: string }): string => params.display ?? params.target, | |
| + resolveOutboundSessionRoute: (params: { target: string }) => { | |
| + const to = normalizeThreemaTarget(params.target); | |
| + return { | |
| + sessionKey: `threema:${to}`, | |
| + baseSessionKey: `threema:${to}`, | |
| + peer: { kind: "direct" as const, id: to }, | |
| + chatType: "direct" as const, | |
| + from: "default", | |
| + to, | |
| + }; | |
| }, | |
| }, | |
| - | |
| - // ============================================================================ | |
| - // Config Adapter - Required for channel registration | |
| - // ============================================================================ | |
| config: { | |
| - listAccountIds: (cfg: OpenClawConfig): string[] => { | |
| - const threemaCfg = getThreemaConfig(cfg); | |
| - return threemaCfg?.gatewayId ? ["default"] : []; | |
| - }, | |
| - | |
| - resolveAccount: ( | |
| - cfg: OpenClawConfig, | |
| - accountId?: string | null | |
| - ): ResolvedThreemaAccount => { | |
| + listAccountIds: (cfg: OpenClawConfig): string[] => getThreemaConfig(cfg)?.gatewayId ? ["default"] : [], | |
| + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ResolvedThreemaAccount => { | |
| const threemaCfg = getThreemaConfig(cfg); | |
| return { | |
| accountId: accountId ?? "default", | |
| @@ -1188,899 +492,224 @@ const threemaChannel = { | |
| textChunkLimit: threemaCfg?.textChunkLimit, | |
| }; | |
| }, | |
| - | |
| - isEnabled: (account: ResolvedThreemaAccount, cfg?: OpenClawConfig): boolean => { | |
| - return account.enabled !== false && !!account.gatewayId; | |
| - }, | |
| - | |
| - isConfigured: (account: ResolvedThreemaAccount, cfg?: OpenClawConfig): boolean => { | |
| - return !!(account.gatewayId && account.secretKey); | |
| - }, | |
| - | |
| - resolveAllowFrom: (params: { | |
| - cfg: OpenClawConfig; | |
| - accountId?: string | null; | |
| - }): string[] | undefined => { | |
| - const threemaCfg = getThreemaConfig(params.cfg); | |
| - return threemaCfg?.allowFrom; | |
| - }, | |
| - | |
| - describeAccount: ( | |
| - account: ResolvedThreemaAccount, | |
| - cfg?: OpenClawConfig | |
| - ): ChannelAccountSnapshot => { | |
| - return { | |
| - accountId: account.accountId, | |
| - name: account.gatewayId, | |
| - enabled: account.enabled !== false, | |
| - configured: !!(account.gatewayId && account.secretKey), | |
| - linked: !!account.privateKey, | |
| - webhookPath: account.webhookPath, | |
| - }; | |
| - }, | |
| + isEnabled: (account: ResolvedThreemaAccount): boolean => account.enabled !== false && !!account.gatewayId, | |
| + isConfigured: (account: ResolvedThreemaAccount): boolean => !!(account.gatewayId && account.secretKey), | |
| + resolveAllowFrom: (params: { cfg: OpenClawConfig; accountId?: string | null }): string[] | undefined => getThreemaConfig(params.cfg)?.allowFrom, | |
| + describeAccount: (account: ResolvedThreemaAccount): ChannelAccountSnapshot => ({ | |
| + accountId: account.accountId, | |
| + name: account.gatewayId, | |
| + enabled: account.enabled !== false, | |
| + configured: !!(account.gatewayId && account.secretKey), | |
| + linked: !!account.privateKey, | |
| + running: false, | |
| + connected: false, | |
| + webhookPath: account.webhookPath, | |
| + }), | |
| }, | |
| - | |
| - // ============================================================================ | |
| - // Outbound Adapter - Critical for message tool to work | |
| - // ============================================================================ | |
| outbound: { | |
| deliveryMode: "direct" as const, | |
| textChunkLimit: 3500, | |
| chunkerMode: "text" as const, | |
| - | |
| - // Text chunker for long messages | |
| - chunker: (text: string, limit: number): string[] => { | |
| - return chunkText(text, limit); | |
| - }, | |
| - | |
| - // Target resolution - validates Threema IDs | |
| - resolveTarget: (params: { | |
| - cfg?: OpenClawConfig; | |
| - to?: string; | |
| - allowFrom?: string[]; | |
| - accountId?: string | null; | |
| - mode?: "explicit" | "implicit" | "heartbeat"; | |
| - }): { ok: true; to: string } | { ok: false; error: Error } => { | |
| + chunker: (text: string, limit: number): string[] => chunkText(text, limit), | |
| + resolveTarget: (params: { to?: string }): { ok: true; to: string } | { ok: false; error: Error } => { | |
| const raw = params.to?.trim(); | |
| - | |
| - if (!raw) { | |
| - return { | |
| - ok: false, | |
| - error: new Error( | |
| - "Threema target required. Provide an 8-character Threema ID (e.g., XXXX1234)." | |
| - ), | |
| - }; | |
| - } | |
| - | |
| + if (!raw) return { ok: false, error: new Error("Threema target required") }; | |
| const normalized = normalizeThreemaTarget(raw); | |
| - | |
| - if (!isValidThreemaId(normalized)) { | |
| - return { | |
| - ok: false, | |
| - error: new Error( | |
| - `Invalid Threema ID "${raw}". Expected 8 uppercase alphanumeric characters.` | |
| - ), | |
| - }; | |
| - } | |
| - | |
| - // For explicit mode, validate against allowlist if configured | |
| - if (params.mode === "explicit" && params.allowFrom?.length) { | |
| - const normalizedAllowFrom = params.allowFrom.map((id) => | |
| - normalizeThreemaTarget(id) | |
| - ); | |
| - if (!normalizedAllowFrom.includes(normalized)) { | |
| - return { | |
| - ok: false, | |
| - error: new Error( | |
| - `Threema ID "${normalized}" not in allowlist. Add to channels.threema.allowFrom or use /approve.` | |
| - ), | |
| - }; | |
| - } | |
| - } | |
| - | |
| + if (!isValidThreemaId(normalized)) return { ok: false, error: new Error(`Invalid Threema ID "${raw}"`) }; | |
| return { ok: true, to: normalized }; | |
| }, | |
| - | |
| - // Send text message | |
| - sendText: async ( | |
| - ctx: ChannelOutboundContext | |
| - ): Promise<OutboundDeliveryResult> => { | |
| + sendText: async (ctx: ChannelOutboundContext): Promise<OutboundDeliveryResult> => { | |
| const threemaCfg = getThreemaConfig(ctx.cfg); | |
| - if (!threemaCfg?.gatewayId || !threemaCfg?.secretKey) { | |
| - throw new Error( | |
| - "Threema not configured: missing gatewayId or secretKey" | |
| - ); | |
| - } | |
| - | |
| + if (!threemaCfg) throw new Error("Threema not configured"); | |
| const client = new ThreemaClient(threemaCfg); | |
| const to = normalizeThreemaTarget(ctx.to); | |
| - | |
| - let messageId: string; | |
| - if (client.isE2EEnabled) { | |
| - messageId = await client.sendE2E(to, ctx.text); | |
| - } else { | |
| - messageId = await client.sendSimple(to, ctx.text); | |
| - } | |
| - | |
| - return { | |
| - channel: "threema", | |
| - messageId: messageId.trim(), | |
| - chatId: to, | |
| - timestamp: Date.now(), | |
| - }; | |
| + const messageId = client.isE2EEnabled ? await client.sendE2E(to, ctx.text) : await client.sendSimple(to, ctx.text); | |
| + return { channel: "threema", messageId: messageId.trim(), chatId: to, timestamp: Date.now() }; | |
| }, | |
| - | |
| - // Send media (file message) | |
| - sendMedia: async ( | |
| - ctx: ChannelOutboundContext | |
| - ): Promise<OutboundDeliveryResult> => { | |
| + sendMedia: async (ctx: ChannelOutboundContext): Promise<OutboundDeliveryResult> => { | |
| const threemaCfg = getThreemaConfig(ctx.cfg); | |
| - if (!threemaCfg?.gatewayId || !threemaCfg?.secretKey) { | |
| - throw new Error( | |
| - "Threema not configured: missing gatewayId or secretKey" | |
| - ); | |
| - } | |
| - | |
| + if (!threemaCfg) throw new Error("Threema not configured"); | |
| const client = new ThreemaClient(threemaCfg); | |
| const to = normalizeThreemaTarget(ctx.to); | |
| - | |
| - if (!client.isE2EEnabled) { | |
| - throw new Error("Threema media sending requires E2E mode (privateKey)"); | |
| - } | |
| - | |
| - if (!ctx.mediaUrl) { | |
| - throw new Error("No media URL provided"); | |
| - } | |
| - | |
| - // Handle local file paths and URLs | |
| - let filePath: string; | |
| - let tempFilePath: string | null = null; // Track temp file for cleanup | |
| + if (!client.isE2EEnabled) throw new Error("Threema media requires E2E mode"); | |
| + if (!ctx.mediaUrl) throw new Error("No media URL"); | |
| + let filePath: string; | |
| + let tempFilePath: string | null = null; | |
| try { | |
| - if ( | |
| - ctx.mediaUrl.startsWith("/") || | |
| - ctx.mediaUrl.startsWith("file://") | |
| - ) { | |
| - // Local file path - validate it's within allowed media directory | |
| - const rawPath = ctx.mediaUrl.replace("file://", ""); | |
| - const validation = validateLocalMediaPath(rawPath); | |
| - if (!validation.valid) { | |
| - throw new Error(validation.error || "Local file path not allowed outside media directory"); | |
| - } | |
| - filePath = validation.realPath!; | |
| - } else if (ctx.mediaUrl.startsWith("https://")) { | |
| - // Only allow HTTPS URLs (no HTTP for security) | |
| - | |
| - // SSRF protection: block private/internal IPs (hostname check) | |
| - if (isPrivateUrl(ctx.mediaUrl)) { | |
| - throw new Error("Private/internal URLs not allowed for security."); | |
| - } | |
| - | |
| - // DNS rebinding protection: resolve hostname and check resolved IP | |
| - const parsed = new URL(ctx.mediaUrl); | |
| - const dnsCheck = await resolveAndCheckPrivate(parsed.hostname); | |
| - if (dnsCheck.isPrivate) { | |
| - throw new Error(`URL resolves to private/internal IP (${dnsCheck.resolvedIP || "blocked domain"})`); | |
| - } | |
| - | |
| - const tempDir = path.join( | |
| - process.env.HOME || "/tmp", | |
| - ".openclaw", | |
| - "media", | |
| - "temp" | |
| - ); | |
| - if (!fs.existsSync(tempDir)) { | |
| - fs.mkdirSync(tempDir, { recursive: true, mode: 0o700 }); | |
| - } | |
| - | |
| - // Fetch with timeout and size limit | |
| - const controller = new AbortController(); | |
| - const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s timeout | |
| - | |
| - try { | |
| - const res = await fetch(ctx.mediaUrl, { signal: controller.signal, redirect: "error" }); | |
| - | |
| - if (!res.ok) { | |
| - throw new Error(`Failed to download media: ${res.status}`); | |
| - } | |
| - | |
| - // DoS protection: check Content-Length before downloading | |
| - const contentLength = res.headers.get("content-length"); | |
| - const MAX_MEDIA_SIZE = 50 * 1024 * 1024; // 50MB | |
| - if (contentLength && parseInt(contentLength, 10) > MAX_MEDIA_SIZE) { | |
| - throw new Error(`Media too large (${contentLength} bytes > 50MB limit)`); | |
| - } | |
| - | |
| + if (ctx.mediaUrl.startsWith("/") || ctx.mediaUrl.startsWith("file://")) { | |
| + filePath = ctx.mediaUrl.replace("file://", ""); | |
| + } else { | |
| + const res = await fetch(ctx.mediaUrl); | |
| + if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); | |
| const buffer = await res.arrayBuffer(); | |
| - clearTimeout(timeoutId); // Clear AFTER full download completes | |
| - | |
| - // Also check actual size after download | |
| - if (buffer.byteLength > MAX_MEDIA_SIZE) { | |
| - throw new Error(`Media too large (${buffer.byteLength} bytes > 50MB limit)`); | |
| - } | |
| - | |
| - const urlPath = new URL(ctx.mediaUrl).pathname; | |
| - const rawFileName = path.basename(urlPath) || `media_${Date.now()}`; | |
| - // Sanitize downloaded filename | |
| - const fileName = sanitizeFilename(rawFileName); | |
| - filePath = path.join(tempDir, fileName); | |
| - tempFilePath = filePath; // Mark for cleanup | |
| + const tempDir = path.join(process.env.HOME || "/tmp", ".openclaw", "media", "temp"); | |
| + if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true, mode: 0o700 }); | |
| + filePath = path.join(tempDir, `media_${Date.now()}`); | |
| + tempFilePath = filePath; | |
| fs.writeFileSync(filePath, new Uint8Array(buffer), { mode: 0o600 }); | |
| - } catch (err: any) { | |
| - clearTimeout(timeoutId); | |
| - if (err.name === "AbortError") { | |
| - throw new Error("Media download timed out (15s)"); | |
| - } | |
| - throw err; | |
| - } | |
| - } else if (ctx.mediaUrl.startsWith("http://")) { | |
| - throw new Error("HTTP URLs not allowed for security. Use HTTPS."); | |
| - } else { | |
| - throw new Error(`Unsupported media URL format: ${ctx.mediaUrl}`); | |
| - } | |
| - | |
| - if (!fs.existsSync(filePath)) { | |
| - throw new Error(`Media file not found: ${filePath}`); | |
| } | |
| - | |
| const mimeType = getMimeFromPath(filePath); | |
| - const caption = ctx.text || undefined; | |
| - | |
| - const messageId = await client.sendFileE2E(to, filePath, mimeType, caption); | |
| - | |
| - return { | |
| - channel: "threema", | |
| - messageId: messageId.trim(), | |
| - chatId: to, | |
| - timestamp: Date.now(), | |
| - }; | |
| + const messageId = await client.sendFileE2E(to, filePath, mimeType, ctx.text || undefined); | |
| + return { channel: "threema", messageId: messageId.trim(), chatId: to, timestamp: Date.now() }; | |
| } finally { | |
| - // Cleanup temp file | |
| - if (tempFilePath && fs.existsSync(tempFilePath)) { | |
| - try { | |
| - fs.unlinkSync(tempFilePath); | |
| - } catch { | |
| - // Ignore cleanup errors | |
| - } | |
| - } | |
| + if (tempFilePath && fs.existsSync(tempFilePath)) { try { fs.unlinkSync(tempFilePath); } catch {} } | |
| } | |
| }, | |
| }, | |
| - | |
| - // ============================================================================ | |
| - // Messaging Adapter - For target formatting and hints | |
| - // ============================================================================ | |
| - messaging: { | |
| - normalizeTarget: (raw: string): string | undefined => { | |
| - const normalized = normalizeThreemaTarget(raw); | |
| - return isValidThreemaId(normalized) ? normalized : undefined; | |
| - }, | |
| - | |
| - targetResolver: { | |
| - looksLikeId: (raw: string, normalized?: string): boolean => { | |
| - const id = normalized ?? normalizeThreemaTarget(raw); | |
| - return isValidThreemaId(id); | |
| - }, | |
| - hint: "Threema ID (8 uppercase chars, e.g., XXXX1234)", | |
| - }, | |
| - | |
| - formatTargetDisplay: (params: { | |
| - target: string; | |
| - display?: string; | |
| - }): string => { | |
| - return params.display ?? params.target; | |
| - }, | |
| - }, | |
| - | |
| - // ============================================================================ | |
| - // Security Adapter | |
| - // ============================================================================ | |
| security: { | |
| - resolveDmPolicy: (ctx: { | |
| - cfg: OpenClawConfig; | |
| - accountId?: string | null; | |
| - account: ResolvedThreemaAccount; | |
| - }) => { | |
| - const policy = ctx.account.dmPolicy ?? "allowlist"; | |
| - const allowFrom = ctx.account.allowFrom; | |
| - return { | |
| - policy, | |
| - allowFrom: allowFrom ?? null, | |
| + resolveDmPolicy: (ctx: { account: ResolvedThreemaAccount }) => ({ | |
| + policy: ctx.account.dmPolicy ?? "allowlist", | |
| + allowFrom: ctx.account.allowFrom ?? null, | |
| allowFromPath: "channels.threema.allowFrom", | |
| approveHint: "Add Threema ID to channels.threema.allowFrom in config", | |
| normalizeEntry: normalizeThreemaTarget, | |
| - }; | |
| - }, | |
| - }, | |
| - | |
| - // ============================================================================ | |
| - // Pairing Adapter | |
| - // ============================================================================ | |
| - pairing: { | |
| - idLabel: "Threema ID", | |
| - normalizeAllowEntry: normalizeThreemaTarget, | |
| + }), | |
| }, | |
| - | |
| - // ============================================================================ | |
| - // Status Adapter | |
| - // ============================================================================ | |
| + pairing: { idLabel: "Threema ID", normalizeAllowEntry: normalizeThreemaTarget }, | |
| status: { | |
| - defaultRuntime: { | |
| - accountId: "default", | |
| - running: false, | |
| - connected: false, | |
| - } as ChannelAccountSnapshot, | |
| - | |
| - buildAccountSnapshot: (params: { | |
| - account: ResolvedThreemaAccount; | |
| - cfg: OpenClawConfig; | |
| - runtime?: ChannelAccountSnapshot; | |
| - }): ChannelAccountSnapshot => { | |
| - const { account, runtime } = params; | |
| - return { | |
| - accountId: account.accountId, | |
| - name: account.gatewayId, | |
| - enabled: account.enabled !== false, | |
| - configured: !!(account.gatewayId && account.secretKey), | |
| - linked: !!account.privateKey, | |
| - running: runtime?.running ?? false, | |
| - connected: runtime?.connected ?? false, | |
| - lastConnectedAt: runtime?.lastConnectedAt, | |
| - webhookPath: account.webhookPath, | |
| - }; | |
| - }, | |
| - | |
| - resolveAccountState: (params: { | |
| - account: ResolvedThreemaAccount; | |
| - cfg: OpenClawConfig; | |
| - configured: boolean; | |
| - enabled: boolean; | |
| - }): | |
| - | "linked" | |
| - | "not linked" | |
| - | "configured" | |
| - | "not configured" | |
| - | "enabled" | |
| - | "disabled" => { | |
| + defaultRuntime: { accountId: "default", running: false, connected: false } as ChannelAccountSnapshot, | |
| + buildAccountSnapshot: (params: { account: ResolvedThreemaAccount; runtime?: ChannelAccountSnapshot }): ChannelAccountSnapshot => ({ | |
| + ...params.runtime, | |
| + accountId: params.account.accountId, | |
| + name: params.account.gatewayId, | |
| + enabled: params.account.enabled !== false, | |
| + configured: !!(params.account.gatewayId && params.account.secretKey), | |
| + linked: !!params.account.privateKey, | |
| + running: params.runtime?.running ?? false, | |
| + connected: params.runtime?.connected ?? false, | |
| + }), | |
| + resolveAccountState: (params: { account: ResolvedThreemaAccount; enabled: boolean }): any => { | |
| if (!params.enabled) return "disabled"; | |
| - if (!params.configured) return "not configured"; | |
| + if (!params.account.gatewayId) return "not configured"; | |
| if (params.account.privateKey) return "linked"; | |
| return "configured"; | |
| }, | |
| }, | |
| - | |
| - // ============================================================================ | |
| - // Gateway Adapter - For starting/stopping the channel service | |
| - // ============================================================================ | |
| gateway: { | |
| startAccount: async (ctx: ChannelGatewayContext): Promise<void> => { | |
| - const { account, cfg, log, setStatus, getStatus, abortSignal } = ctx; | |
| - | |
| - if (!account.gatewayId || !account.secretKey) { | |
| - log?.warn?.("Threema not configured - missing gatewayId or secretKey"); | |
| - return; | |
| - } | |
| - | |
| - const client = new ThreemaClient(account); | |
| - | |
| - log?.info?.( | |
| - `Threema Gateway starting: ${account.gatewayId} (${client.isE2EEnabled ? "E2E" : "Basic"} mode)` | |
| - ); | |
| - | |
| - if (client.isE2EEnabled) { | |
| - log?.info?.(`E2E public key: ${client.ownPublicKey}`); | |
| - } | |
| - | |
| - // Bind shared status so the webhook handler can report activity | |
| + const { account, setStatus, getStatus, abortSignal } = ctx; | |
| + if (!account.gatewayId || !account.secretKey) return; | |
| channelStatus.bind(getStatus, setStatus); | |
| - | |
| - setStatus({ | |
| - ...getStatus(), | |
| - running: true, | |
| - connected: true, | |
| - lastConnectedAt: Date.now(), | |
| - lastEventAt: Date.now(), | |
| - }); | |
| - | |
| - // Periodic health heartbeat β update lastEventAt every 15 min so the | |
| - // health-monitor doesn't think we're stuck (webhook channels are passive). | |
| - const heartbeatInterval = setInterval(() => { | |
| - setStatus({ | |
| - ...getStatus(), | |
| - lastEventAt: Date.now(), | |
| - }); | |
| - }, 15 * 60 * 1000); | |
| - | |
| - // Keep the promise alive until abortSignal fires β resolving immediately | |
| - // causes the gateway to treat the channel as "exited" and restart it. | |
| + setStatus({ ...getStatus(), running: true, connected: true, lastConnectedAt: Date.now() }); | |
| await new Promise<void>((resolve) => { | |
| if (abortSignal.aborted) return resolve(); | |
| - abortSignal.addEventListener("abort", () => { | |
| - clearInterval(heartbeatInterval); | |
| - resolve(); | |
| - }, { once: true }); | |
| + abortSignal.addEventListener("abort", () => resolve(), { once: true }); | |
| }); | |
| - | |
| - log?.info?.("Threema Gateway stopped via abort signal"); | |
| }, | |
| - | |
| stopAccount: async (ctx: ChannelGatewayContext): Promise<void> => { | |
| - const { setStatus, getStatus, log } = ctx; | |
| - log?.info?.("Threema Gateway stopping"); | |
| - setStatus({ | |
| - ...getStatus(), | |
| - running: false, | |
| - connected: false, | |
| - }); | |
| + const { setStatus, getStatus } = ctx; | |
| + setStatus({ ...getStatus(), running: false, connected: false }); | |
| }, | |
| }, | |
| }; | |
| -// ============================================================================ | |
| -// Plugin Registration | |
| -// ============================================================================ | |
| - | |
| export const id = "threema"; | |
| export const name = "Threema Gateway"; | |
| -export const version = "0.5.2"; | |
| -export const description = | |
| - "Threema messaging channel via Threema Gateway API (E2E encrypted, with media support)"; | |
| - | |
| -export default function register(api: any) { | |
| - try { | |
| - const config = api.config as OpenClawConfig; | |
| - const threemaCfg = getThreemaConfig(config); | |
| - const runtime = api.runtime; | |
| +export const version = "0.6.0"; | |
| +export const description = "Native Threema messaging channel (E2E encrypted)"; | |
| - // Store runtime reference for wakeAgent() to use requestHeartbeatNow | |
| - _runtimeApi = runtime; | |
| +let _runtimeApi: any = null; | |
| - // Debug: check if requestHeartbeatNow is available | |
| - api.logger?.info?.(`Threema wake API: runtime.system.requestHeartbeatNow=${typeof runtime?.system?.requestHeartbeatNow}`); | |
| +export default function register(api: any) { | |
| + _runtimeApi = api; | |
| + const config = api.config as OpenClawConfig; | |
| + const threemaCfg = getThreemaConfig(config); | |
| - // Register the channel plugin | |
| - api.registerChannel({ plugin: threemaChannel }); | |
| - api.logger?.info?.("Threema channel plugin registered"); | |
| + api.registerChannel({ plugin: threemaChannel }); | |
| - // Register webhook handler for incoming E2E messages | |
| if (threemaCfg?.privateKey) { | |
| const client = new ThreemaClient(threemaCfg); | |
| const webhookPath = threemaCfg.webhookPath ?? "/threema/webhook"; | |
| const ownGatewayId = threemaCfg.gatewayId; | |
| - const dmPolicy = threemaCfg.dmPolicy ?? "allowlist"; | |
| - const allowFrom = threemaCfg.allowFrom ?? []; | |
| - | |
| - // Webhook handler β Threema's API POSTs without our gateway token, | |
| - // so we need an unauthenticated route. The webhook has its own MAC-based auth. | |
| - // | |
| - // Forward-compatible: use registerHttpRoute with auth:"none" (2026.3.2+), | |
| - // fall back to registerHttpHandler for 2026.3.1. | |
| - const webhookHandler = async (req: any, res: any): Promise<void> => { | |
| - if (req.method !== "POST") { | |
| - res.writeHead(405, { "Content-Type": "text/plain" }); | |
| - res.end("Method Not Allowed"); | |
| - return; | |
| - } | |
| + const webhookHandler = async (req: any, res: any): Promise<void> => { | |
| + api.logger?.info?.(`Threema webhook received method=${req.method}`); | |
| + if (req.method !== "POST") { res.writeHead(405); res.end(); return; } | |
| try { | |
| - // Parse body with size limit (128KB max) | |
| - let body: any; | |
| - try { | |
| - body = await readBodyLimited(req, 128 * 1024); | |
| - } catch (err: any) { | |
| - if (err.message === "BODY_TOO_LARGE") { | |
| - res.writeHead(413, { "Content-Type": "text/plain" }); | |
| - res.end("Request Entity Too Large"); | |
| - return; | |
| - } | |
| - throw err; | |
| - } | |
| - | |
| - const { from, to, nonce, box, nickname, messageId, mac, date } = body; | |
| - | |
| - // Info log without PII, debug log with details | |
| - api.logger?.info?.("Threema webhook received"); | |
| - api.logger?.debug?.(`Threema webhook details: from=${from} messageId=${messageId}`); | |
| - | |
| - // === VALIDATION PHASE === | |
| - | |
| - // 1. Validate field formats | |
| - const validation = validateWebhookFields(body); | |
| - if (!validation.valid) { | |
| - api.logger?.warn?.(`Threema webhook validation failed: ${validation.error}`); | |
| - res.writeHead(400, { "Content-Type": "text/plain" }); | |
| - res.end(validation.error || "Invalid request"); | |
| - return; | |
| - } | |
| - | |
| - // 2. Verify MAC BEFORE any further processing | |
| - const computedMac = computeThreemaCallbackMac( | |
| - from, to, messageId, date || "", nonce, box, threemaCfg.secretKey | |
| - ); | |
| - if (!verifyMac(mac, computedMac)) { | |
| - api.logger?.warn?.(`Threema webhook MAC verification failed for messageId=${messageId}`); | |
| - res.writeHead(401, { "Content-Type": "text/plain" }); | |
| - res.end("MAC verification failed"); | |
| - return; | |
| - } | |
| - | |
| - // 3. Verify recipient matches our gateway ID | |
| - if (to !== ownGatewayId) { | |
| - api.logger?.warn?.(`Threema webhook: 'to' (${to}) doesn't match gatewayId (${ownGatewayId})`); | |
| - res.writeHead(400, { "Content-Type": "text/plain" }); | |
| - res.end("Invalid recipient"); | |
| - return; | |
| - } | |
| - | |
| - // 4. Validate date (not older than 60 minutes) | |
| - // Threema retries delivery on webhook errors, so messages can arrive late. | |
| - // 60 min is generous enough for retries while still rejecting stale replays. | |
| - if (date) { | |
| - const msgTimestamp = parseInt(date, 10) * 1000; // Convert to ms | |
| - const now = Date.now(); | |
| - const maxAge = 60 * 60 * 1000; // 60 minutes in ms | |
| - if (now - msgTimestamp > maxAge) { | |
| - api.logger?.warn?.(`Threema webhook: message too old (date=${date})`); | |
| - res.writeHead(400, { "Content-Type": "text/plain" }); | |
| - res.end("Message too old"); | |
| - return; | |
| - } | |
| - } | |
| - | |
| - // 5. DM-Policy enforcement BEFORE decryption | |
| - if (dmPolicy === "disabled") { | |
| - api.logger?.info?.(`Threema DM policy: disabled, rejecting from=${from}`); | |
| - res.writeHead(403, { "Content-Type": "text/plain" }); | |
| - res.end("DMs are disabled"); | |
| - return; | |
| - } | |
| - | |
| - if (dmPolicy === "allowlist") { | |
| - // Default-deny: empty allowlist = block all | |
| - if (allowFrom.length === 0) { | |
| - api.logger?.info?.("Threema DM policy: allowlist with empty allowlist, rejecting"); | |
| - res.writeHead(403, { "Content-Type": "text/plain" }); | |
| - res.end("Allowlist empty"); | |
| - return; | |
| - } | |
| - | |
| - const normalizedFrom = normalizeThreemaTarget(from); | |
| - const normalizedAllowFrom = allowFrom.map(id => normalizeThreemaTarget(id)); | |
| - if (!normalizedAllowFrom.includes(normalizedFrom)) { | |
| - api.logger?.debug?.("Threema DM policy: sender not in allowlist"); | |
| - res.writeHead(403, { "Content-Type": "text/plain" }); | |
| - res.end("Sender not authorized"); | |
| - return; | |
| - } | |
| - } | |
| - | |
| - // 6. Message-ID dedup (replay protection) - AFTER MAC, BEFORE decrypt | |
| - if (isDuplicateMsgId(messageId)) { | |
| - api.logger?.info?.(`Threema webhook: duplicate messageId=${messageId}, ignoring`); | |
| - res.writeHead(200, { "Content-Type": "text/plain" }); | |
| - res.end("OK"); | |
| - return; | |
| - } | |
| - | |
| - // === PROCESSING PHASE === | |
| - | |
| - // Report activity to health-monitor (prevents "stuck" restarts) | |
| - channelStatus.updateActivity(); | |
| - | |
| - // Decrypt the message | |
| - const senderPubKey = await client.getPublicKey(from); | |
| - const decrypted = client.decryptMessage( | |
| - senderPubKey, | |
| - hexToBytes(nonce), | |
| - hexToBytes(box) | |
| - ); | |
| - | |
| - const senderLabel = nickname || from; | |
| - | |
| - if (decrypted?.type === 0x01 && decrypted?.text) { | |
| - // Text message - PII only on debug level | |
| - api.logger?.info?.("Threema text message received"); | |
| - api.logger?.debug?.(`Threema text from=${from} messageId=${messageId}`); | |
| - | |
| - // Dispatch to OpenClaw via enqueueSystemEvent | |
| - const enqueue = runtime?.system?.enqueueSystemEvent; | |
| - if (enqueue) { | |
| - const envelope = `[Threema message from ${senderLabel} (${from})]\n${decrypted.text}`; | |
| - enqueue(envelope, { | |
| - sessionKey: "agent:main:main", | |
| - deliveryContext: { | |
| - channel: "threema", | |
| - to: from, | |
| - from: from, | |
| - accountId: "default", | |
| - }, | |
| - }); | |
| - api.logger?.info?.("Threema message dispatched via enqueueSystemEvent"); | |
| - | |
| - // Wake the agent | |
| - wakeAgent(config); | |
| - } else { | |
| - api.logger?.warn?.("enqueueSystemEvent not available"); | |
| - } | |
| - } else if (decrypted?.type === 0x17 && decrypted?.fileMessage) { | |
| - // File message - PII only on debug level | |
| - const fileMsg = decrypted.fileMessage; | |
| - api.logger?.info?.(`Threema file received: ${fileMsg.m} (${fileMsg.s} bytes)`); | |
| - api.logger?.debug?.(`Threema file from=${from} messageId=${messageId}`); | |
| - | |
| - // Process the file: download, decrypt, save, maybe transcribe | |
| - const result = await processFileMessage( | |
| - client, | |
| - fileMsg, | |
| - from, | |
| - api.logger | |
| - ); | |
| - | |
| - // Dispatch to OpenClaw | |
| - const enqueue = runtime?.system?.enqueueSystemEvent; | |
| - if (enqueue) { | |
| - let envelope = `[Threema file from ${senderLabel} (${from})]`; | |
| - | |
| - if (fileMsg.d) { | |
| - envelope += `\nCaption: ${fileMsg.d}`; | |
| - } | |
| - | |
| - envelope += `\nFile: ${fileMsg.n || "unnamed"} (${fileMsg.m}, ${fileMsg.s} bytes)`; | |
| + const body = await readBodyLimited(req, 128 * 1024); | |
| + const { from, to, nonce, box, messageId, mac, date } = body; | |
| + const computedMac = computeThreemaCallbackMac(from, to, messageId, date || "", nonce, box, threemaCfg.secretKey); | |
| + if (!verifyMac(mac, computedMac) || to !== ownGatewayId) { res.writeHead(401); res.end(); return; } | |
| + | |
| + channelStatus.updateActivity(); | |
| + const decrypted = client.decryptMessage(await client.getPublicKey(from), hexToBytes(nonce), hexToBytes(box)); | |
| + if (!decrypted) { res.writeHead(200); res.end("OK"); return; } | |
| + | |
| + const ctx: MsgContext = { | |
| + From: from, To: ownGatewayId, SessionKey: `threema:${from}`, | |
| + Provider: "threema", Surface: "threema", OriginatingChannel: "threema", OriginatingTo: from, | |
| + Timestamp: date ? parseInt(date, 10) * 1000 : Date.now(), | |
| + }; | |
| - if (result?.filePath) { | |
| - envelope += `\nSaved to: ${result.filePath}`; | |
| + if (decrypted.type === 0x01) ctx.Body = decrypted.text; | |
| + else if (decrypted.type === 0x17) { | |
| + const result = await processFileMessage(client, decrypted.fileMessage, from, api.logger); | |
| + ctx.Body = `[File] ${decrypted.fileMessage.d || ""}`; | |
| + ctx.MediaPath = result?.filePath; | |
| + ctx.Transcript = result?.transcription; | |
| + } else { res.writeHead(200); res.end("OK"); return; } | |
| + | |
| + const channelRuntime = api.runtime?.channel; | |
| + api.logger?.info?.(`Threema debug: channelRuntime keys: ${Object.keys(channelRuntime || {}).join(", ")}`); | |
| + if (channelRuntime?.reply) { | |
| + api.logger?.info?.(`Threema debug: channelRuntime.reply keys: ${Object.keys(channelRuntime.reply || {}).join(", ")}`); | |
| + } | |
| - if (result.transcription) { | |
| - envelope += `\n\nπ€ Audio transcription:\n${result.transcription}`; | |
| + if (channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) { | |
| + api.logger?.info?.(`Threema initiating native dispatch for ${from}`); | |
| + res.writeHead(200); | |
| + res.end("OK"); | |
| + await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({ | |
| + ctx, cfg: config, | |
| + dispatcherOptions: { | |
| + deliver: async (payload) => { | |
| + if (payload.mediaPath) { | |
| + await threemaChannel.outbound.sendMedia({ cfg: config, to: from, text: payload.text, mediaUrl: payload.mediaPath }); | |
| + } else { | |
| + await threemaChannel.outbound.sendText({ cfg: config, to: from, text: payload.text }); | |
| } | |
| - } else { | |
| - envelope += `\nβ οΈ Failed to download/decrypt file`; | |
| + }, | |
| + typingCallbacks: { | |
| + pulse: async () => {}, | |
| + stop: async () => {}, | |
| } | |
| - | |
| - enqueue(envelope, { | |
| - sessionKey: "agent:main:main", | |
| - deliveryContext: { | |
| - channel: "threema", | |
| - to: from, | |
| - from: from, | |
| - accountId: "default", | |
| - mediaPath: result?.filePath, | |
| - transcription: result?.transcription, | |
| - }, | |
| - }); | |
| - api.logger?.info?.("Threema file message dispatched via enqueueSystemEvent"); | |
| - | |
| - // Wake the agent | |
| - wakeAgent(config); | |
| } | |
| - } else if (decrypted?.type === 0x80) { | |
| - // Delivery receipt - PII only on debug level | |
| - const statusNames: Record<number, string> = { | |
| - 1: "received", | |
| - 2: "read", | |
| - 3: "acknowledged", | |
| - 4: "declined", | |
| - }; | |
| - api.logger?.info?.( | |
| - `Threema delivery receipt: ${statusNames[decrypted.status || 0] || "unknown"}` | |
| - ); | |
| - api.logger?.debug?.(`Threema receipt from=${from}`); | |
| - } else if (decrypted) { | |
| - api.logger?.info?.( | |
| - `Threema message type 0x${decrypted.type.toString(16)} (not yet supported)` | |
| - ); | |
| - api.logger?.debug?.(`Threema unsupported from=${from} messageId=${messageId}`); | |
| - } | |
| - | |
| - res.writeHead(200, { "Content-Type": "text/plain" }); | |
| - res.end("OK"); | |
| - } catch (err: any) { | |
| - // Don't log full error details that might contain sensitive data | |
| - api.logger?.error?.(`Threema webhook error: ${err.message}`); | |
| - if (!res.headersSent) { | |
| - res.writeHead(500, { "Content-Type": "text/plain" }); | |
| - res.end("Internal Server Error"); | |
| - } | |
| + }); | |
| + } else { | |
| + api.runtime?.system?.enqueueSystemEvent(`[Threema from ${from}]\n${ctx.Body}`, { | |
| + sessionKey: `threema:${from}`, | |
| + deliveryContext: { channel: "threema", to: from, from: from }, | |
| + }); | |
| + res.writeHead(200); res.end("OK"); | |
| } | |
| - return; | |
| + } catch (err: any) { | |
| + api.logger?.error?.(`Threema webhook error: ${err.message}`); | |
| + if (!res.headersSent) { res.writeHead(500); res.end(); } | |
| + } | |
| }; | |
| - // Register the webhook β Threema's callback server POSTs without our | |
| - // gateway auth token, so we need an unauthenticated route. | |
| - // | |
| - // Strategy: prefer registerHttpHandler (available in 2026.3.1, removed in 2026.3.2) | |
| - // because it bypasses gateway auth. Fall back to registerHttpRoute with auth:"none" | |
| - // (supported from 2026.3.2+). The order matters: registerHttpRoute in 2026.3.1 | |
| - // silently ignores auth:"none" and applies gateway auth, breaking external webhooks. | |
| - if (api.registerHttpHandler) { | |
| - // 2026.3.1: registerHttpHandler with manual path matching (no gateway auth) | |
| - api.registerHttpHandler(async (req: any, res: any): Promise<boolean> => { | |
| - const url = new URL(req.url ?? "/", "http://localhost"); | |
| - if (url.pathname !== webhookPath) return false; | |
| - await webhookHandler(req, res); | |
| - return true; | |
| - }); | |
| - api.logger?.info?.(`Threema webhook registered via registerHttpHandler at ${webhookPath}`); | |
| - } else if (api.registerHttpRoute) { | |
| - // 2026.3.2+: registerHttpHandler removed, use registerHttpRoute with auth:"plugin" | |
| - // auth:"plugin" = no gateway auth, plugin handles its own auth (MAC verification) | |
| - api.registerHttpRoute({ | |
| - path: webhookPath, | |
| - auth: "plugin", | |
| - handler: webhookHandler, | |
| - }); | |
| - api.logger?.info?.(`Threema webhook registered via registerHttpRoute (auth:plugin) at ${webhookPath}`); | |
| - } else { | |
| - api.logger?.error?.("No HTTP registration method available β Threema webhook NOT registered!"); | |
| + if (api.registerHttpRoute) api.registerHttpRoute({ path: webhookPath, auth: "plugin", handler: webhookHandler }); | |
| + else if (api.registerHttpHandler) { | |
| + api.registerHttpHandler(async (req: any, res: any) => { | |
| + const url = new URL(req.url ?? "/", "http://localhost"); | |
| + if (url.pathname !== webhookPath) return false; | |
| + await webhookHandler(req, res); | |
| + return true; | |
| + }); | |
| } | |
| } | |
| - // Register CLI commands | |
| - api.registerCli?.( | |
| - ({ program }: any) => { | |
| - const threema = program | |
| - .command("threema") | |
| - .description("Threema Gateway utilities"); | |
| - | |
| - threema | |
| - .command("keygen") | |
| - .description("Generate a new NaCl key pair for E2E mode") | |
| - .action(() => { | |
| + api.registerCli?.(({ program }: any) => { | |
| + const threema = program.command("threema").description("Threema Gateway utilities"); | |
| + threema.command("keygen").description("Generate NaCl keys").action(() => { | |
| const keys = generateKeyPair(); | |
| - console.log("\nπ Generated Threema E2E Key Pair\n"); | |
| - console.log("Private Key (keep secret!):"); | |
| - console.log(` ${keys.privateKey}\n`); | |
| - console.log("Public Key (share with contacts):"); | |
| - console.log(` ${keys.publicKey}\n`); | |
| - console.log("Add to config:"); | |
| - console.log( | |
| - ` channels.threema.privateKey = "${keys.privateKey}"\n` | |
| - ); | |
| - }); | |
| - | |
| - threema | |
| - .command("send <to> <message>") | |
| - .description("Send a test message") | |
| - .action(async (to: string, message: string) => { | |
| - if (!threemaCfg?.gatewayId) { | |
| - console.error("Threema not configured"); | |
| - process.exit(1); | |
| - } | |
| - | |
| - const client = new ThreemaClient(threemaCfg); | |
| - const normalizedTo = normalizeThreemaTarget(to); | |
| - | |
| - try { | |
| - const msgId = client.isE2EEnabled | |
| - ? await client.sendE2E(normalizedTo, message) | |
| - : await client.sendSimple(normalizedTo, message); | |
| - console.log( | |
| - `β Message sent to ${normalizedTo} (ID: ${msgId.trim()})` | |
| - ); | |
| - } catch (err: any) { | |
| - console.error(`β Send failed: ${err.message}`); | |
| - process.exit(1); | |
| - } | |
| - }); | |
| - | |
| - threema | |
| - .command("send-file <to> <filepath>") | |
| - .description("Send a file (E2E mode only)") | |
| - .option("-c, --caption <text>", "Caption for the file") | |
| - .action(async (to: string, filepath: string, opts: any) => { | |
| - if (!threemaCfg?.gatewayId || !threemaCfg?.privateKey) { | |
| - console.error("Threema E2E not configured"); | |
| - process.exit(1); | |
| - } | |
| - | |
| - const client = new ThreemaClient(threemaCfg); | |
| - const normalizedTo = normalizeThreemaTarget(to); | |
| - | |
| - if (!fs.existsSync(filepath)) { | |
| - console.error(`File not found: ${filepath}`); | |
| - process.exit(1); | |
| - } | |
| - | |
| - const mimeType = getMimeFromPath(filepath); | |
| - | |
| - try { | |
| - const msgId = await client.sendFileE2E( | |
| - normalizedTo, | |
| - filepath, | |
| - mimeType, | |
| - opts.caption | |
| - ); | |
| - console.log( | |
| - `β File sent to ${normalizedTo} (ID: ${msgId.trim()})` | |
| - ); | |
| - } catch (err: any) { | |
| - console.error(`β Send failed: ${err.message}`); | |
| - process.exit(1); | |
| - } | |
| - }); | |
| - | |
| - threema | |
| - .command("status") | |
| - .description("Show Threema Gateway status") | |
| - .action(() => { | |
| - if (!threemaCfg?.gatewayId) { | |
| - console.log("β Threema not configured"); | |
| - return; | |
| - } | |
| - | |
| - console.log(`\nπ± Threema Gateway Status\n`); | |
| - console.log(`Gateway ID: ${threemaCfg.gatewayId}`); | |
| - console.log( | |
| - `Mode: ${threemaCfg.privateKey ? "E2E (encrypted)" : "Basic (server-side)"}` | |
| - ); | |
| - console.log(`DM Policy: ${threemaCfg.dmPolicy ?? "allowlist"}`); | |
| - console.log( | |
| - `Webhook: ${threemaCfg.webhookPath ?? "(not configured)"}` | |
| - ); | |
| - console.log(`Media support: β enabled`); | |
| - | |
| - if (threemaCfg.allowFrom?.length) { | |
| - console.log(`Allowed: ${threemaCfg.allowFrom.join(", ")}`); | |
| - } | |
| - console.log(); | |
| - }); | |
| - }, | |
| - { commands: ["threema"] } | |
| - ); | |
| - | |
| - api.logger?.info?.("Threema Gateway plugin loaded (with media support)"); | |
| - } catch (err: any) { | |
| - api.logger?.error?.(`Threema plugin registration failed: ${err.message}`); | |
| - // Don't rethrow - let the gateway continue without Threema rather than crashing | |
| - } | |
| -} | |
| - | |
| -/** | |
| - * Wake the agent to process new messages | |
| - */ | |
| -// Shared reference to runtime API β set during register(), used by wakeAgent() | |
| -let _runtimeApi: any = null; | |
| - | |
| -function wakeAgent(config: any) { | |
| - // Prefer the new requestHeartbeatNow API (2026.3.2+) β direct, no HTTP roundtrip | |
| - if (_runtimeApi?.system?.requestHeartbeatNow) { | |
| - try { | |
| - _runtimeApi.system.requestHeartbeatNow({ sessionKey: "agent:main:main" }); | |
| - return; | |
| - } catch {} | |
| - } | |
| - | |
| - // Fallback: HTTP wake call for older versions | |
| - const gatewayPort = config?.gateway?.port || 18789; | |
| - | |
| - let hooksToken: string | undefined = process.env.OPENCLAW_HOOKS_TOKEN; | |
| - | |
| - if (!hooksToken) { | |
| - try { | |
| - const rawConfig = JSON.parse(fs.readFileSync( | |
| - path.join(process.env.HOME || "/tmp", ".openclaw", "openclaw.json"), "utf8" | |
| - )); | |
| - hooksToken = rawConfig?.hooks?.token; | |
| - } catch {} | |
| - } | |
| - | |
| - if (!hooksToken) return; | |
| - | |
| - const wakeUrl = `http://127.0.0.1:${gatewayPort}/hooks/wake`; | |
| - const wakeBody = JSON.stringify({ | |
| - text: "Threema message received", | |
| - mode: "now", | |
| - }); | |
| - const headers: Record<string, string> = { | |
| - "Content-Type": "application/json", | |
| - "Authorization": `Bearer ${hooksToken}`, | |
| - }; | |
| - | |
| - fetch(wakeUrl, { method: "POST", headers, body: wakeBody }) | |
| - .then(res => { | |
| - // Don't log response body (log-redaction) | |
| - if (!res.ok) console.error(`[Threema] Wake failed: ${res.status}`); | |
| - }) | |
| - .catch(err => console.error(`[Threema] Wake error: ${err.message}`)); | |
| + console.log(`Private: ${keys.privateKey}\nPublic: ${keys.publicKey}`); | |
| + }); | |
| + threema.command("send <to> <message>").action(async (to: string, message: string) => { | |
| + const client = new ThreemaClient(threemaCfg!); | |
| + const msgId = await (client.isE2EEnabled ? client.sendE2E(normalizeThreemaTarget(to), message) : client.sendSimple(normalizeThreemaTarget(to), message)); | |
| + console.log(`β Sent ${msgId}`); | |
| + }); | |
| + }, { commands: ["threema"] }); | |
| } | |
| - | |
| -- | |
| 2.47.3 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment