From 2caefe26737514c6e7602d8300d69173fd637953 Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Thu, 23 Jul 2026 16:24:01 +0700 Subject: [PATCH 1/9] feat(manifests): ship full dashboard i18n and tighten catalog manifest gates Backfill manifest i18n (name, description, config titles) for http-action, typebot-connector, and voice-transcription across all eight dashboard locales, and declare sessionScoped explicitly in after-hours and faq-bot. catalog.mjs now hard-fails on a missing sessionScoped field and on a stable plugin without testedOpenWAVersion, and warns on missing/partial i18n. The catalog test asserts every stable plugin ships a complete locale set. --- after-hours/manifest.json | 1 + faq-bot/manifest.json | 1 + http-action/manifest.json | 18 ++++++++++++++++++ scripts/catalog.mjs | 19 +++++++++++++++++++ scripts/catalog.test.mjs | 15 ++++++++++++--- typebot-connector/manifest.json | 18 ++++++++++++++++++ voice-transcription/manifest.json | 16 +++++++++++++++- 7 files changed, 84 insertions(+), 4 deletions(-) diff --git a/after-hours/manifest.json b/after-hours/manifest.json index 32988f4..034085e 100644 --- a/after-hours/manifest.json +++ b/after-hours/manifest.json @@ -26,6 +26,7 @@ "permissions": [ "messages:send" ], + "sessionScoped": true, "sessions": [ "*" ], diff --git a/faq-bot/manifest.json b/faq-bot/manifest.json index 280f097..bcc5ba6 100644 --- a/faq-bot/manifest.json +++ b/faq-bot/manifest.json @@ -26,6 +26,7 @@ "permissions": [ "messages:send" ], + "sessionScoped": true, "sessions": [ "*" ], diff --git a/http-action/manifest.json b/http-action/manifest.json index eaa8f5c..016febe 100644 --- a/http-action/manifest.json +++ b/http-action/manifest.json @@ -35,5 +35,23 @@ "actions": { "type": "textarea", "title": "Actions (JSON array)", "required": true, "description": "JSON array of actions. Example: [{\"id\":\"check-order\",\"match\":{\"type\":\"prefix\",\"value\":\"cek-order \"},\"request\":{\"method\":\"GET\",\"path\":\"/orders/{{args.0}}\"},\"replyTemplate\":\"Order {{response.orderId}}: {{response.status}}\"}]. Parsed at config time." } } + }, + "i18n": { + "es": { "name": "Bot de acciones HTTP", "description": "Ejecuta solicitudes seguras a API REST desde comandos de WhatsApp y muestra las respuestas JSON en el chat.", + "config": { "baseUrl": { "title": "URL base" }, "authType": { "title": "Tipo de autenticación" }, "authToken": { "title": "Token / clave de API" }, "apiKeyHeader": { "title": "Encabezado de clave de API" }, "respondInGroups": { "title": "Responder en grupos" }, "timeoutMs": { "title": "Tiempo de espera de la solicitud (ms)" }, "cooldownSeconds": { "title": "Enfriamiento (segundos)" }, "actions": { "title": "Acciones (array JSON)" } } }, + "fr": { "name": "Bot d'actions HTTP", "description": "Déclenche des requêtes API REST sécurisées depuis des commandes WhatsApp et affiche les réponses JSON dans le chat.", + "config": { "baseUrl": { "title": "URL de base" }, "authType": { "title": "Type d'authentification" }, "authToken": { "title": "Jeton / clé API" }, "apiKeyHeader": { "title": "En-tête de clé API" }, "respondInGroups": { "title": "Répondre dans les groupes" }, "timeoutMs": { "title": "Délai d'attente de la requête (ms)" }, "cooldownSeconds": { "title": "Temps de recharge (secondes)" }, "actions": { "title": "Actions (tableau JSON)" } } }, + "it": { "name": "Bot di azioni HTTP", "description": "Esegue richieste sicure a API REST dai comandi WhatsApp e mostra le risposte JSON nella chat.", + "config": { "baseUrl": { "title": "URL di base" }, "authType": { "title": "Tipo di autenticazione" }, "authToken": { "title": "Token / chiave API" }, "apiKeyHeader": { "title": "Header della chiave API" }, "respondInGroups": { "title": "Rispondi nei gruppi" }, "timeoutMs": { "title": "Timeout della richiesta (ms)" }, "cooldownSeconds": { "title": "Tempo di attesa (secondi)" }, "actions": { "title": "Azioni (array JSON)" } } }, + "ar": { "name": "بوت إجراءات HTTP", "description": "يشغّل طلبات REST API آمنة من أوامر WhatsApp ويعرض استجابات JSON في المحادثة.", + "config": { "baseUrl": { "title": "الرابط الأساسي" }, "authType": { "title": "نوع المصادقة" }, "authToken": { "title": "الرمز / مفتاح API" }, "apiKeyHeader": { "title": "ترويسة مفتاح API" }, "respondInGroups": { "title": "الرد في المجموعات" }, "timeoutMs": { "title": "مهلة الطلب (ميلي ثانية)" }, "cooldownSeconds": { "title": "فترة التهدئة (بالثواني)" }, "actions": { "title": "الإجراءات (مصفوفة JSON)" } } }, + "he": { "name": "בוט פעולות HTTP", "description": "מפעיל בקשות REST API בטוחות מפקודות WhatsApp ומציג תגובות JSON חזרה בצ'אט.", + "config": { "baseUrl": { "title": "כתובת בסיס" }, "authType": { "title": "סוג אימות" }, "authToken": { "title": "אסימון / מפתח API" }, "apiKeyHeader": { "title": "כותרת מפתח API" }, "respondInGroups": { "title": "השב בקבוצות" }, "timeoutMs": { "title": "פסק זמן לבקשה (ms)" }, "cooldownSeconds": { "title": "צינון (שניות)" }, "actions": { "title": "פעולות (מערך JSON)" } } }, + "te": { "name": "HTTP యాక్షన్ బాట్", "description": "WhatsApp కమాండ్‌ల నుండి సురక్షిత REST API అభ్యర్థనలను ట్రిగర్ చేసి, JSON ప్రతిస్పందనలను చాట్‌లో చూపిస్తుంది.", + "config": { "baseUrl": { "title": "బేస్ URL" }, "authType": { "title": "ప్రామాణీకరణ రకం" }, "authToken": { "title": "టోకెన్ / API కీ" }, "apiKeyHeader": { "title": "API కీ హెడర్" }, "respondInGroups": { "title": "గ్రూపులలో స్పందించు" }, "timeoutMs": { "title": "అభ్యర్థన గడువు (ms)" }, "cooldownSeconds": { "title": "కూల్‌డౌన్ (సెకన్లు)" }, "actions": { "title": "చర్యలు (JSON శ్రేణి)" } } }, + "zh-CN": { "name": "HTTP 动作机器人", "description": "从 WhatsApp 命令触发安全的 REST API 请求,并将 JSON 响应展示回聊天中。", + "config": { "baseUrl": { "title": "基础 URL" }, "authType": { "title": "认证类型" }, "authToken": { "title": "令牌 / API 密钥" }, "apiKeyHeader": { "title": "API 密钥请求头" }, "respondInGroups": { "title": "在群组中回复" }, "timeoutMs": { "title": "请求超时(毫秒)" }, "cooldownSeconds": { "title": "冷却时间(秒)" }, "actions": { "title": "动作(JSON 数组)" } } }, + "zh-HK": { "name": "HTTP 動作機器人", "description": "從 WhatsApp 指令觸發安全的 REST API 請求,並將 JSON 回應顯示回對話中。", + "config": { "baseUrl": { "title": "基礎 URL" }, "authType": { "title": "認證類型" }, "authToken": { "title": "權杖 / API 金鑰" }, "apiKeyHeader": { "title": "API 金鑰標頭" }, "respondInGroups": { "title": "在群組中回覆" }, "timeoutMs": { "title": "請求逾時(毫秒)" }, "cooldownSeconds": { "title": "冷卻時間(秒)" }, "actions": { "title": "動作(JSON 陣列)" } } } } } diff --git a/scripts/catalog.mjs b/scripts/catalog.mjs index 81763e2..204dfc9 100644 --- a/scripts/catalog.mjs +++ b/scripts/catalog.mjs @@ -22,6 +22,24 @@ function discoverPlugins() { .sort(); } +const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'it', 'ar', 'he', 'te', 'zh-CN', 'zh-HK']; + +// Manifest hygiene gates (hard failures) and soft warnings. Keep these aligned with PLUGIN-STANDARD.md. +function validateManifest(id, manifest) { + if (manifest.sessionScoped === undefined) { + throw new Error(`${id}: manifest.json must declare "sessionScoped" explicitly (true or false)`); + } + if (manifest.status === 'stable' && !manifest.testedOpenWAVersion) { + throw new Error(`${id}: status "stable" requires testedOpenWAVersion (the newest host actually smoke-tested)`); + } + if (!manifest.i18n || Object.keys(manifest.i18n).length === 0) { + console.warn(`⚠ ${id}: no i18n block — dashboard shows English only`); + } else { + const missing = SUPPORTED_LOCALES.filter((l) => l !== 'en' && !manifest.i18n[l]); + if (missing.length) console.warn(`⚠ ${id}: i18n missing locale(s): ${missing.join(', ')}`); + } +} + // Top released CHANGELOG heading: `## [x.y.z] — YYYY-MM-DD` (skips `## [Unreleased]`). function readChangelogTop(id) { const path = join(ROOT, id, 'CHANGELOG.md'); @@ -39,6 +57,7 @@ function buildEntry(id) { `${id}: version drift — manifest.json is ${manifest.version} but the top CHANGELOG entry is ${changelog.version}`, ); } + validateManifest(id, manifest); return { id: manifest.id, name: manifest.name, diff --git a/scripts/catalog.test.mjs b/scripts/catalog.test.mjs index e5050b1..9c25fe1 100644 --- a/scripts/catalog.test.mjs +++ b/scripts/catalog.test.mjs @@ -1,6 +1,6 @@ // Catalog passthrough test: verifies that plugins.json carries i18n blocks for any plugin whose -// manifest.json has an `i18n` field. Run after `npm run catalog` (Task 6 adds the blocks and -// regenerates; until then `withI18n` is empty and the assertion at line 14 will fail as expected). +// manifest.json has an `i18n` field. plugins.json is generated by `npm run catalog`; the +// version/changelog drift gate lives in scripts/catalog.mjs itself. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -9,7 +9,16 @@ test('plugins.json carries i18n for a plugin whose manifest has it', () => { const catalog = JSON.parse(readFileSync(new URL('../plugins.json', import.meta.url), 'utf8')); const entries = catalog.plugins ?? catalog; const withI18n = entries.filter(e => e.i18n); - // After Task 6 every plugin has i18n; assert at least one and that it has locale keys. assert.ok(withI18n.length > 0, 'expected at least one plugin entry with i18n'); for (const e of withI18n) assert.ok(e.i18n.es && e.i18n['zh-CN'], `${e.id} i18n should include es + zh-CN`); }); + +test('every stable plugin ships a full i18n block', () => { + const catalog = JSON.parse(readFileSync(new URL('../plugins.json', import.meta.url), 'utf8')); + const entries = catalog.plugins ?? catalog; + const LOCALES = ['es', 'fr', 'it', 'ar', 'he', 'te', 'zh-CN', 'zh-HK']; + for (const e of entries.filter(e => e.status === 'stable')) { + assert.ok(e.i18n, `${e.id} (stable) has no i18n block`); + for (const l of LOCALES) assert.ok(e.i18n[l], `${e.id} i18n missing locale ${l}`); + } +}); diff --git a/typebot-connector/manifest.json b/typebot-connector/manifest.json index 62d0a84..7420c76 100644 --- a/typebot-connector/manifest.json +++ b/typebot-connector/manifest.json @@ -66,5 +66,23 @@ "description": "Only for older self-hosted Typebot that uploads files to a separate S3/MinIO host. Leave empty for Typebot main (same-origin upload proxy)." } } + }, + "i18n": { + "es": { "name": "Conector de Typebot", "description": "Ejecuta un flujo de Typebot como el cerebro de un bot de WhatsApp: los mensajes entrantes alimentan una sesión de chat de Typebot a través de la Chat API en vivo, y las respuestas del bot —texto, multimedia e inputs de opción numerada— se devuelven a WhatsApp. Inicia automáticamente cada chat, gestiona los pasos de subida de archivos y se reinicia cuando el flujo termina o tras un tiempo de inactividad. Se ejecuta en sandbox dentro del worker de plugins; no requiere URL pública ni webhook.", + "config": { "apiHost": { "title": "URL del host de Typebot" }, "publicId": { "title": "ID público de Typebot" }, "apiToken": { "title": "Token de API" }, "respondInGroups": { "title": "Responder en chats de grupo" }, "sessionTimeoutMinutes": { "title": "Reinicio por inactividad (minutos)" }, "passContactVariables": { "title": "Pasar variables de contacto" }, "mediaHost": { "title": "Host de medios/subida" } } }, + "fr": { "name": "Connecteur Typebot", "description": "Exécute un flux Typebot comme cerveau d'un bot WhatsApp : les messages entrants alimentent une session de chat Typebot via la Chat API en direct, et les réponses du bot — texte, médias et choix numérotés — sont renvoyées vers WhatsApp. Démarre automatiquement chaque chat, gère les étapes de téléversement de fichiers et se réinitialise à la fin du flux ou après un délai d'inactivité. S'exécute en sandbox dans le worker de plugins ; aucune URL publique ni webhook requis.", + "config": { "apiHost": { "title": "URL de l'hôte Typebot" }, "publicId": { "title": "ID public Typebot" }, "apiToken": { "title": "Jeton API" }, "respondInGroups": { "title": "Répondre dans les chats de groupe" }, "sessionTimeoutMinutes": { "title": "Réinitialisation après inactivité (minutes)" }, "passContactVariables": { "title": "Transmettre les variables de contact" }, "mediaHost": { "title": "Hôte média/téléversement" } } }, + "it": { "name": "Connettore Typebot", "description": "Esegue un flusso Typebot come cervello di un bot WhatsApp: i messaggi in arrivo alimentano una sessione di chat Typebot tramite la Chat API live, e le risposte del bot — testo, media e scelte numerate — vengono rinviate a WhatsApp. Avvia automaticamente ogni chat, gestisce i passaggi di caricamento file e si reimposta quando il flusso termina o dopo un timeout di inattività. Eseguito in sandbox nel worker dei plugin; non richiede URL pubblico né webhook.", + "config": { "apiHost": { "title": "URL dell'host Typebot" }, "publicId": { "title": "ID pubblico Typebot" }, "apiToken": { "title": "Token API" }, "respondInGroups": { "title": "Rispondi nelle chat di gruppo" }, "sessionTimeoutMinutes": { "title": "Reset per inattività (minuti)" }, "passContactVariables": { "title": "Passa le variabili di contatto" }, "mediaHost": { "title": "Host media/caricamento" } } }, + "ar": { "name": "موصّل Typebot", "description": "يشغّل تدفق Typebot كعقل لبوت WhatsApp: تغذّي الرسائل الواردة جلسة دردشة Typebot عبر Chat API المباشرة، وتُعاد ردود البوت — نصوص ووسائط وخيارات مرقّمة — إلى WhatsApp. يبدأ كل محادثة تلقائيًا، ويتعامل مع خطوات رفع الملفات، ويعيد الضبط عند انتهاء التدفق أو بعد مهلة خمول. يعمل في بيئة معزولة داخل عامل الإضافات؛ لا يتطلب رابطًا عامًا أو webhook.", + "config": { "apiHost": { "title": "رابط مضيف Typebot" }, "publicId": { "title": "المعرّف العام لـ Typebot" }, "apiToken": { "title": "رمز API" }, "respondInGroups": { "title": "الرد في محادثات المجموعات" }, "sessionTimeoutMinutes": { "title": "إعادة الضبط بعد الخمول (بالدقائق)" }, "passContactVariables": { "title": "تمرير متغيرات جهة الاتصال" }, "mediaHost": { "title": "مضيف الوسائط/الرفع" } } }, + "he": { "name": "מחבר Typebot", "description": "מריץ זרימת Typebot כמוח של בוט WhatsApp: הודעות נכנסות מזינות סשן צ'אט של Typebot דרך ה-Chat API החי, ותשובות הבוט — טקסט, מדיה ובחירות ממוספרות — נשלחות חזרה ל-WhatsApp. מתחיל כל צ'אט אוטומטית, מטפל בשלבי העלאת קבצים, ומתאפס כשהזרימה מסתיימת או לאחר פסק זמן חוסר פעילות. רץ בארגז חול בתוך עובד התוספים; לא נדרשת כתובת ציבורית או webhook.", + "config": { "apiHost": { "title": "כתובת המארח של Typebot" }, "publicId": { "title": "מזהה ציבורי של Typebot" }, "apiToken": { "title": "אסימון API" }, "respondInGroups": { "title": "השב בצ'אטים קבוצתיים" }, "sessionTimeoutMinutes": { "title": "איפוס חוסר פעילות (דקות)" }, "passContactVariables": { "title": "העבר משתני איש קשר" }, "mediaHost": { "title": "מארח מדיה/העלאה" } } }, + "te": { "name": "Typebot కనెక్టర్", "description": "Typebot ఫ్లోను WhatsApp బాట్ యొక్క మెదడుగా నడుపుతుంది: ఇన్‌బౌండ్ సందేశాలు లైవ్ Chat API ద్వారా Typebot చాట్ సెషన్‌ను నడుపుతాయి, మరియు బాట్ ప్రత్యుత్తరాలు — టెక్స్ట్, మీడియా మరియు సంఖ్యా-ఎంపిక ఇన్‌పుట్‌లు — తిరిగి WhatsApp కు పంపబడతాయి. ప్రతి చాట్‌ను స్వయంచాలకంగా ప్రారంభిస్తుంది, ఫైల్-అప్‌లోడ్ దశలను నిర్వహిస్తుంది, మరియు ఫ్లో ముగిసినప్పుడు లేదా ఐడిల్ టైమ్‌అవుట్ తర్వాత రీసెట్ అవుతుంది. ప్లగిన్ వర్కర్‌లో శ్యాండ్‌బాక్స్‌లో నడుస్తుంది; పబ్లిక్ URL లేదా webhook అవసరం లేదు.", + "config": { "apiHost": { "title": "Typebot హోస్ట్ URL" }, "publicId": { "title": "Typebot పబ్లిక్ ID" }, "apiToken": { "title": "API టోకెన్" }, "respondInGroups": { "title": "గ్రూప్ చాట్‌లలో స్పందించు" }, "sessionTimeoutMinutes": { "title": "ఐడిల్ రీసెట్ (నిమిషాలు)" }, "passContactVariables": { "title": "కాంటాక్ట్ వేరియబుల్‌లను పంపు" }, "mediaHost": { "title": "మీడియా/అప్‌లోడ్ హోస్ట్" } } }, + "zh-CN": { "name": "Typebot 连接器", "description": "将 Typebot 流程作为 WhatsApp 机器人的大脑运行:传入消息通过实时 Chat API 驱动 Typebot 聊天会话,机器人的回复——文本、媒体和编号选项输入——被发送回 WhatsApp。自动开始每个会话,处理文件上传步骤,并在流程结束或空闲超时后重置。在插件工作器中以沙箱方式运行;无需公共 URL 或 webhook。", + "config": { "apiHost": { "title": "Typebot 主机 URL" }, "publicId": { "title": "Typebot 公共 ID" }, "apiToken": { "title": "API 令牌" }, "respondInGroups": { "title": "在群聊中回复" }, "sessionTimeoutMinutes": { "title": "空闲重置(分钟)" }, "passContactVariables": { "title": "传递联系人变量" }, "mediaHost": { "title": "媒体/上传主机" } } }, + "zh-HK": { "name": "Typebot 連接器", "description": "將 Typebot 流程作為 WhatsApp 機器人的大腦運行:傳入訊息透過實時 Chat API 驅動 Typebot 聊天工作階段,機器人的回覆——文字、媒體及編號選項輸入——會傳回 WhatsApp。自動開始每個對話,處理檔案上傳步驟,並在流程結束或閒置逾時後重設。在插件工作器中以沙箱方式運行;無需公共 URL 或 webhook。", + "config": { "apiHost": { "title": "Typebot 主機 URL" }, "publicId": { "title": "Typebot 公開 ID" }, "apiToken": { "title": "API 權杖" }, "respondInGroups": { "title": "在群組對話中回覆" }, "sessionTimeoutMinutes": { "title": "閒置重設(分鐘)" }, "passContactVariables": { "title": "傳遞聯絡人變數" }, "mediaHost": { "title": "媒體/上傳主機" } } } } } diff --git a/voice-transcription/manifest.json b/voice-transcription/manifest.json index 980dba2..ca3890c 100644 --- a/voice-transcription/manifest.json +++ b/voice-transcription/manifest.json @@ -143,6 +143,20 @@ "es": { "name": "Transcripción de Notas de Voz", "description": "Transcribe las notas de voz entrantes de WhatsApp a texto mediante un backend de voz a texto compatible con OpenAI (Speaches/faster-whisper autoalojado o Groq/OpenAI) y entrega un evento message.transcription a tu webhook, para que los bots y la IA puedan leer y responder al audio. Fuera de la ruta de entrega de mensajes; desactivado hasta que se habilite." - } + }, + "fr": { "name": "Transcription de notes vocales", "description": "Transcrit les notes vocales WhatsApp entrantes en texte via un backend de reconnaissance vocale compatible OpenAI (Speaches/faster-whisper auto-hébergé ou Groq/OpenAI hébergé) et envoie un événement `message.transcription` à votre webhook — pour que les bots et l'IA puissent lire et répondre à l'audio. Hors du chemin de livraison des messages ; désactivé jusqu'à activation.", + "config": { "sttBaseUrl": { "title": "URL de base STT" }, "sttApiKey": { "title": "Clé API STT" }, "model": { "title": "Modèle" }, "language": { "title": "Indication de langue" }, "provider": { "title": "Étiquette du fournisseur" }, "timeoutMs": { "title": "Délai d'attente STT (ms)" }, "enabledMessageTypes": { "title": "Types de messages à transcrire" }, "maxSizeBytes": { "title": "Taille audio max (octets)" }, "maxPerHour": { "title": "Transcriptions max / heure / session" }, "deliveryWebhookUrl": { "title": "URL du webhook de livraison" }, "deliverySecret": { "title": "Secret de livraison" }, "deliveryTimeoutMs": { "title": "Délai d'attente de livraison (ms)" }, "chatDelivery": { "title": "Livraison dans le chat" } } }, + "it": { "name": "Trascrizione note vocali", "description": "Trascrive le note vocali WhatsApp in arrivo in testo tramite un backend speech-to-text compatibile con OpenAI (Speaches/faster-whisper self-hosted o Groq/OpenAI in hosting) e consegna un evento `message.transcription` al tuo webhook — così bot e IA possono leggere e rispondere all'audio. Fuori dal percorso di consegna dei messaggi; disabilitato finché non viene abilitato.", + "config": { "sttBaseUrl": { "title": "URL base STT" }, "sttApiKey": { "title": "Chiave API STT" }, "model": { "title": "Modello" }, "language": { "title": "Suggerimento lingua" }, "provider": { "title": "Etichetta del provider" }, "timeoutMs": { "title": "Timeout STT (ms)" }, "enabledMessageTypes": { "title": "Tipi di messaggio da trascrivere" }, "maxSizeBytes": { "title": "Dimensione max audio (byte)" }, "maxPerHour": { "title": "Trascrizioni max / ora / sessione" }, "deliveryWebhookUrl": { "title": "URL del webhook di consegna" }, "deliverySecret": { "title": "Segreto di consegna" }, "deliveryTimeoutMs": { "title": "Timeout di consegna (ms)" }, "chatDelivery": { "title": "Consegna in chat" } } }, + "ar": { "name": "نسخ الملاحظات الصوتية", "description": "ينسخ الملاحظات الصوتية الواردة في WhatsApp إلى نص عبر خلفية تحويل كلام إلى نص متوافقة مع OpenAI (Speaches/faster-whisper ذاتية الاستضافة أو Groq/OpenAI المستضافة) ويسلّم حدث `message.transcription` إلى webhook الخاص بك — حتى تتمكن البوتات والذكاء الاصطناعي من قراءة الصوت والرد عليه. خارج مسار تسليم الرسائل؛ معطّل حتى يتم تفعيله.", + "config": { "sttBaseUrl": { "title": "الرابط الأساسي لـ STT" }, "sttApiKey": { "title": "مفتاح API لـ STT" }, "model": { "title": "النموذج" }, "language": { "title": "تلميح اللغة" }, "provider": { "title": "تسمية المزوّد" }, "timeoutMs": { "title": "مهلة STT (ميلي ثانية)" }, "enabledMessageTypes": { "title": "أنواع الرسائل المراد نسخها" }, "maxSizeBytes": { "title": "الحد الأقصى لحجم الصوت (بايت)" }, "maxPerHour": { "title": "أقصى عدد نسخ / ساعة / جلسة" }, "deliveryWebhookUrl": { "title": "رابط webhook للتسليم" }, "deliverySecret": { "title": "سرّ التسليم" }, "deliveryTimeoutMs": { "title": "مهلة التسليم (ميلي ثانية)" }, "chatDelivery": { "title": "التسليم داخل المحادثة" } } }, + "he": { "name": "תמלול הודעות קוליות", "description": "מתמלל הודעות קוליות נכנסות ב-WhatsApp לטקסט באמצעות שרת דיבור-לטקסט תואם OpenAI (Speaches/faster-whisper באירוח עצמי או Groq/OpenAI באירוח) ומעביר אירוע `message.transcription` ל-webhook שלך — כך שבוטים ו-AI יכולים לקרוא ולהשיב לאודיו. מחוץ לנתיב מסירת ההודעות; מושבת עד שמופעל.", + "config": { "sttBaseUrl": { "title": "כתובת בסיס של STT" }, "sttApiKey": { "title": "מפתח API של STT" }, "model": { "title": "מודל" }, "language": { "title": "רמז שפה" }, "provider": { "title": "תווית ספק" }, "timeoutMs": { "title": "פסק זמן STT (ms)" }, "enabledMessageTypes": { "title": "סוגי הודעות לתמלול" }, "maxSizeBytes": { "title": "גודל אודיו מרבי (בייטים)" }, "maxPerHour": { "title": "מקסימום תמלולים / שעה / סשן" }, "deliveryWebhookUrl": { "title": "כתובת webhook למסירה" }, "deliverySecret": { "title": "סוד מסירה" }, "deliveryTimeoutMs": { "title": "פסק זמן מסירה (ms)" }, "chatDelivery": { "title": "מסירה בתוך הצ'אט" } } }, + "te": { "name": "వాయిస్ నోట్ ట్రాన్స్‌క్రిప్షన్", "description": "ఇన్‌బౌండ్ WhatsApp వాయిస్ నోట్‌లను OpenAI-అనుకూల స్పీచ్-టు-టెక్స్ట్ బ్యాక్‌ఎండ్ (స్వీయ-హోస్ట్ చేసిన Speaches/faster-whisper లేదా హోస్ట్ చేసిన Groq/OpenAI) ద్వారా టెక్స్ట్‌గా మారుస్తుంది మరియు `message.transcription` ఈవెంట్‌ను మీ webhook కు డెలివర్ చేస్తుంది — తద్వారా బాట్‌లు మరియు AI ఆడియోను చదవగలవు మరియు స్పందించగలవు. సందేశ-డెలివరీ మార్గం నుండి బయట; ప్రారంభించే వరకు నిలిపివేయబడింది.", + "config": { "sttBaseUrl": { "title": "STT బేస్ URL" }, "sttApiKey": { "title": "STT API కీ" }, "model": { "title": "మోడల్" }, "language": { "title": "భాషా సూచన" }, "provider": { "title": "ప్రొవైడర్ లేబుల్" }, "timeoutMs": { "title": "STT టైమ్‌అవుట్ (ms)" }, "enabledMessageTypes": { "title": "ట్రాన్స్‌క్రైబ్ చేయాల్సిన సందేశ రకాలు" }, "maxSizeBytes": { "title": "గరిష్ట ఆడియో పరిమాణం (బైట్లు)" }, "maxPerHour": { "title": "గరిష్ట ట్రాన్స్‌క్రిప్షన్‌లు / గంట / సెషన్" }, "deliveryWebhookUrl": { "title": "డెలివరీ webhook URL" }, "deliverySecret": { "title": "డెలివరీ రహస్యం" }, "deliveryTimeoutMs": { "title": "డెలివరీ టైమ్‌అవుట్ (ms)" }, "chatDelivery": { "title": "చాట్‌లో డెలివరీ" } } }, + "zh-CN": { "name": "语音消息转写", "description": "通过兼容 OpenAI 的语音转文字后端(自托管 Speaches/faster-whisper 或托管的 Groq/OpenAI)将传入的 WhatsApp 语音消息转写为文本,并向你的 webhook 投递 `message.transcription` 事件——让机器人和 AI 能够读取并回复语音。不在消息投递路径上;启用前保持禁用。", + "config": { "sttBaseUrl": { "title": "STT 基础 URL" }, "sttApiKey": { "title": "STT API 密钥" }, "model": { "title": "模型" }, "language": { "title": "语言提示" }, "provider": { "title": "提供商标签" }, "timeoutMs": { "title": "STT 超时(毫秒)" }, "enabledMessageTypes": { "title": "要转写的消息类型" }, "maxSizeBytes": { "title": "最大音频大小(字节)" }, "maxPerHour": { "title": "每小时每会话最大转写数" }, "deliveryWebhookUrl": { "title": "投递 webhook URL" }, "deliverySecret": { "title": "投递密钥" }, "deliveryTimeoutMs": { "title": "投递超时(毫秒)" }, "chatDelivery": { "title": "聊天内投递" } } }, + "zh-HK": { "name": "語音訊息轉寫", "description": "透過兼容 OpenAI 的語音轉文字後端(自託管 Speaches/faster-whisper 或託管的 Groq/OpenAI)將傳入的 WhatsApp 語音訊息轉寫為文字,並向你的 webhook 投遞 `message.transcription` 事件——讓機器人及 AI 能夠讀取並回覆語音。不在訊息投遞路徑上;啟用前保持停用。", + "config": { "sttBaseUrl": { "title": "STT 基礎 URL" }, "sttApiKey": { "title": "STT API 金鑰" }, "model": { "title": "模型" }, "language": { "title": "語言提示" }, "provider": { "title": "提供者標籤" }, "timeoutMs": { "title": "STT 逾時(毫秒)" }, "enabledMessageTypes": { "title": "要轉寫的訊息類型" }, "maxSizeBytes": { "title": "最大音訊大小(位元組)" }, "maxPerHour": { "title": "每小時每工作階段最大轉寫數" }, "deliveryWebhookUrl": { "title": "投遞 webhook URL" }, "deliverySecret": { "title": "投遞密鑰" }, "deliveryTimeoutMs": { "title": "投遞逾時(毫秒)" }, "chatDelivery": { "title": "對話內投遞" } } } } } \ No newline at end of file From f62ef3dfcf0a747f3370491bb7df7bf9c5ae593c Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Thu, 23 Jul 2026 16:24:29 +0700 Subject: [PATCH 2/9] test: auto-discover plugin test files instead of hardcoded globs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/run-tests.mjs finds every plugin directory by its manifest.json (the same rule as the catalog) and runs its tests, so a new plugin is covered by npm test without edits to package.json. scripts/*.test.mjs now runs too — the catalog test was previously never executed. A guard test fails if a plugin directory ships without tests. tsconfig include/exclude is now glob-based for the same reason, and package.json declares engines.node >= 22 (the toolchain target) plus a test:coverage script using Node's built-in coverage. --- package.json | 4 +++- scripts/run-tests.mjs | 48 ++++++++++++++++++++++++++++++++++++++ scripts/run-tests.test.mjs | 27 +++++++++++++++++++++ tsconfig.json | 15 ++---------- 4 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 scripts/run-tests.mjs create mode 100644 scripts/run-tests.test.mjs diff --git a/package.json b/package.json index 43ab32a..98b5ab7 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,13 @@ "type": "module", "description": "Official & community plugins for OpenWA.", "license": "MIT", + "engines": { "node": ">=22" }, "scripts": { "build": "for d in */; do if [ -f \"${d}manifest.json\" ]; then node package.mjs \"${d%/}\"; fi; done", "catalog": "node scripts/catalog.mjs", "catalog:check": "node scripts/catalog.mjs --check", - "test": "node --import tsx --test \"gsheets-logger/*.test.ts\" \"faq-bot/*.test.ts\" \"after-hours/*.test.ts\" \"chat-flow/*.test.ts\" \"group-translate/**/*.test.ts\" \"voice-transcription/**/*.test.ts\" \"chatwoot-adapter/**/*.test.ts\" \"typebot-connector/**/*.test.ts\" \"http-action/*.test.ts\" \"supabase-otp-hook/*.test.ts\"", + "test": "node scripts/run-tests.mjs", + "test:coverage": "node scripts/run-tests.mjs --experimental-test-coverage", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..8208068 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,48 @@ +// Test runner with auto-discovery: finds every plugin directory (any top-level dir with a +// manifest.json — the same rule as scripts/catalog.mjs) plus scripts/, collects their +// *.test.ts / *.test.mjs files recursively, and runs them with `node --import tsx --test`. +// Extra CLI args are forwarded to the test runner (e.g. `node scripts/run-tests.mjs +// --experimental-test-coverage`). Adding a plugin no longer requires editing package.json. +import { readdirSync, statSync, existsSync } from 'node:fs'; +import { join, dirname, resolve, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const SKIP = new Set(['node_modules', 'scripts', 'docs', '.git', '.github', '.superpowers', 'dist', '.remember']); + +export function discoverPluginDirs(root = ROOT) { + return readdirSync(root) + .filter((name) => !SKIP.has(name) && !name.startsWith('.')) + .filter((name) => statSync(join(root, name)).isDirectory()) + .filter((name) => existsSync(join(root, name, 'manifest.json'))) + .sort(); +} + +function collectTests(dir, out = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) collectTests(path, out); + else if (/\.test\.(ts|mjs)$/.test(entry.name)) out.push(path); + } + return out; +} + +// Only run when invoked directly (the discovery helpers are imported by scripts/run-tests.test.mjs). +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const dirs = [...discoverPluginDirs(), 'scripts']; + const files = dirs.flatMap((d) => collectTests(join(ROOT, d))).sort(); + if (files.length === 0) { + console.error('No test files found.'); + process.exit(1); + } + console.log(`Running ${files.length} test file(s) from ${dirs.length} director(ies): ${dirs.join(', ')}`); + + const result = spawnSync( + process.execPath, + ['--import', 'tsx', '--test', ...process.argv.slice(2), ...files.map((f) => relative(ROOT, f))], + { cwd: ROOT, stdio: 'inherit' }, + ); + process.exit(result.status ?? 1); +} diff --git a/scripts/run-tests.test.mjs b/scripts/run-tests.test.mjs new file mode 100644 index 0000000..a8275d3 --- /dev/null +++ b/scripts/run-tests.test.mjs @@ -0,0 +1,27 @@ +// Guards the auto-discovery in scripts/run-tests.mjs: every plugin directory (a top-level dir with +// a manifest.json) must contain at least one test file, so a new plugin can never silently slip +// through untested the way the old hardcoded glob in package.json allowed. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { discoverPluginDirs } from './run-tests.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function hasTestFile(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) { + if (!entry.isFile()) continue; + if (/\.test\.(ts|mjs)$/.test(entry.name)) return true; + } + return false; +} + +test('every discovered plugin directory contains at least one test file', () => { + const dirs = discoverPluginDirs(ROOT); + assert.ok(dirs.length > 0, 'expected to discover at least one plugin'); + for (const id of dirs) { + assert.ok(hasTestFile(join(ROOT, id)), `${id}: plugin directory has no *.test.ts — add tests`); + } +}); diff --git a/tsconfig.json b/tsconfig.json index 4d0abc9..e00da55 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,17 +9,6 @@ "allowImportingTsExtensions": true, "esModuleInterop": true }, - "include": [ - "gsheets-logger/**/*.ts", - "faq-bot/**/*.ts", - "after-hours/**/*.ts", - "chat-flow/**/*.ts", - "group-translate/**/*.ts", - "voice-transcription/**/*.ts", - "chatwoot-adapter/**/*.ts", - "typebot-connector/**/*.ts", - "http-action/**/*.ts", - "supabase-otp-hook/**/*.ts", - "types/**/*.d.ts" - ] + "include": ["*/**/*.ts", "types/**/*.d.ts"], + "exclude": ["node_modules", ".worktrees", "*/dist"] } From 852683a79ff9241f6775c0903464c0c7f63f64f1 Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Thu, 23 Jul 2026 16:24:29 +0700 Subject: [PATCH 3/9] build: package plugins with an internal zero-dependency zip writer Replace the external zip CLI in package.mjs with a small STORE-method writer (scripts/zip-store.mjs) so packaging works on any platform without extra tooling. Archives are byte-deterministic (fixed timestamps) and covered by round-trip parse tests; output verified against system unzip. --- package.mjs | 17 +++++++--- scripts/zip-store.mjs | 69 ++++++++++++++++++++++++++++++++++++++ scripts/zip-store.test.mjs | 59 ++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 scripts/zip-store.mjs create mode 100644 scripts/zip-store.test.mjs diff --git a/package.mjs b/package.mjs index 26b2510..5e2e1c2 100644 --- a/package.mjs +++ b/package.mjs @@ -1,9 +1,9 @@ import { build } from 'esbuild'; -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; import { rm, mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; +import { zipStore } from './scripts/zip-store.mjs'; const plugin = process.argv[2]; if (!plugin) { @@ -68,8 +68,17 @@ if (manifest.configUi?.entry) { if (!existsSync(join(dir, ui))) fail(`configUi.entry "${ui}" not found`); entries.push(ui.includes('/') ? ui.split('/')[0] : ui); } -const result = spawnSync('zip', ['-r', zipPath, ...entries], { cwd: dir, stdio: 'inherit' }); -if (result.status !== 0) fail('zip failed (is the `zip` CLI installed?)'); + +// Resolve each entry (file or directory) to zip members with forward-slash relative paths. +function collectEntryFiles(base, rel) { + const abs = join(base, rel); + if (statSync(abs).isDirectory()) { + return readdirSync(abs, { withFileTypes: true }).flatMap((e) => collectEntryFiles(base, `${rel}/${e.name}`)); + } + return [{ name: rel, data: readFileSync(abs) }]; +} +const result = entries.flatMap((entry) => collectEntryFiles(dir, entry)); +await writeFile(zipPath, zipStore(result)); // ── Report size + sha256 (release artifacts — surfaced here and in the GitHub Release) ── const buf = readFileSync(zipPath); diff --git a/scripts/zip-store.mjs b/scripts/zip-store.mjs new file mode 100644 index 0000000..1aa6a1c --- /dev/null +++ b/scripts/zip-store.mjs @@ -0,0 +1,69 @@ +// Minimal ZIP writer (STORE method, no compression): local file headers + central directory + EOCD, +// with an in-house CRC-32. Replaces the external `zip` CLI in package.mjs so packaging works on any +// platform with zero dependencies. Entries are written with fixed DOS timestamps (1980-01-01) so +// builds are byte-deterministic. + +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +export function crc32(buf) { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +// entries: [{ name: string (forward-slash relative path), data: Buffer }] +export function zipStore(entries) { + const chunks = []; + const central = []; + let offset = 0; + for (const { name, data } of entries) { + const nameBuf = Buffer.from(name, 'utf8'); + const crc = crc32(data); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); // local file header signature + local.writeUInt16LE(20, 4); // version needed + local.writeUInt16LE(0x0800, 6); // flag: UTF-8 names + local.writeUInt16LE(0, 8); // method: store + local.writeUInt16LE(0, 10); // mod time + local.writeUInt16LE(0x21, 12); // mod date: 1980-01-01 (deterministic) + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); // compressed size + local.writeUInt32LE(data.length, 22); // uncompressed size + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); // extra field length + chunks.push(local, nameBuf, data); + + const cd = Buffer.alloc(46); + cd.writeUInt32LE(0x02014b50, 0); // central directory signature + cd.writeUInt16LE(20, 4); // version made by + cd.writeUInt16LE(20, 6); // version needed + cd.writeUInt16LE(0x0800, 8); + cd.writeUInt16LE(0, 10); + cd.writeUInt16LE(0, 12); + cd.writeUInt16LE(0x21, 14); + cd.writeUInt32LE(crc, 16); + cd.writeUInt32LE(data.length, 20); + cd.writeUInt32LE(data.length, 24); + cd.writeUInt16LE(nameBuf.length, 28); + // extra/comment/disk/internal+external attrs stay 0 + cd.writeUInt32LE(offset, 42); // local header offset + central.push(cd, nameBuf); + offset += 30 + nameBuf.length + data.length; + } + const cdBuf = Buffer.concat(central); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); // end of central directory signature + eocd.writeUInt16LE(entries.length, 8); // entries on this disk + eocd.writeUInt16LE(entries.length, 10); // total entries + eocd.writeUInt32LE(cdBuf.length, 12); + eocd.writeUInt32LE(offset, 16); // central directory offset + return Buffer.concat([...chunks, cdBuf, eocd]); +} diff --git a/scripts/zip-store.test.mjs b/scripts/zip-store.test.mjs new file mode 100644 index 0000000..79135e8 --- /dev/null +++ b/scripts/zip-store.test.mjs @@ -0,0 +1,59 @@ +// Round-trip test for the in-house STORE zip writer: parses the produced archive back +// (central directory + local headers) and asserts entry names, CRC-32, and payload bytes. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { crc32, zipStore } from './zip-store.mjs'; + +function parseZip(buf) { + assert.equal(buf.readUInt32LE(0), 0x04034b50, 'starts with a local file header'); + // EOCD is the last 22 bytes (we never write a comment). + const eocd = buf.length - 22; + assert.equal(buf.readUInt32LE(eocd), 0x06054b50, 'ends with EOCD'); + const count = buf.readUInt16LE(eocd + 10); + const cdOffset = buf.readUInt32LE(eocd + 16); + const entries = []; + for (let i = 0, p = cdOffset; i < count; i++) { + assert.equal(buf.readUInt32LE(p), 0x02014b50, 'central directory entry signature'); + const crc = buf.readUInt32LE(p + 16); + const size = buf.readUInt32LE(p + 24); + const nameLen = buf.readUInt16LE(p + 28); + const extraLen = buf.readUInt16LE(p + 30); + const commentLen = buf.readUInt16LE(p + 32); + const localOffset = buf.readUInt32LE(p + 42); + const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8'); + // Read the payload via the local header. + assert.equal(buf.readUInt32LE(localOffset), 0x04034b50, 'local header signature'); + const lNameLen = buf.readUInt16LE(localOffset + 26); + const lExtraLen = buf.readUInt16LE(localOffset + 28); + const dataStart = localOffset + 30 + lNameLen + lExtraLen; + const data = buf.subarray(dataStart, dataStart + size); + entries.push({ name, crc, data }); + p += 46 + nameLen + extraLen + commentLen; + } + return entries; +} + +test('crc32 matches the well-known check value', () => { + // The canonical CRC-32 check value: crc32("123456789") = 0xCBF43926. + assert.equal(crc32(Buffer.from('123456789')), 0xcbf43926); +}); + +test('zipStore round-trips entry names and bytes', () => { + const input = [ + { name: 'manifest.json', data: Buffer.from('{"id":"x"}', 'utf8') }, + { name: 'dist/index.js', data: Buffer.from('console.log(1);\n', 'utf8') }, + { name: 'config/index.html', data: Buffer.from('\u00e9', 'utf8') }, + ]; + const parsed = parseZip(zipStore(input)); + assert.deepEqual(parsed.map((e) => e.name), input.map((e) => e.name)); + for (let i = 0; i < input.length; i++) { + assert.deepEqual(parsed[i].data, input[i].data, `${input[i].name} payload`); + assert.equal(parsed[i].crc, crc32(input[i].data), `${input[i].name} crc`); + } +}); + +test('zipStore handles binary payloads with NUL bytes', () => { + const bin = Buffer.from([0, 1, 2, 255, 0, 254]); + const [entry] = parseZip(zipStore([{ name: 'bin.dat', data: bin }])); + assert.deepEqual(entry.data, bin); +}); From 4fb948b52fb267279944849fb6fef0e6194c2eaf Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Thu, 23 Jul 2026 16:24:48 +0700 Subject: [PATCH 4/9] refactor: unify cross-plugin helper copies under a drift guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat-lock, multipart, and the LRU cooldown helper existed as near-verbatim copies across plugins (plugins ship as self-contained zips, so copying is deliberate). The copies are now identical and pinned by scripts/shared-copies.test.mjs, which fails the suite if they ever drift apart — a fix in one copy must be applied to all. The multipart copy in voice-transcription gains the CR/LF header sanitization the other copies already had, and the cooldown helper moves to a canonical cooldown.ts imported by after-hours, faq-bot, and http-action. --- after-hours/cooldown.ts | 21 ++++++++++++++++++++ after-hours/index.test.ts | 3 ++- after-hours/index.ts | 22 ++------------------- chatwoot-adapter/chat-lock.ts | 7 ++++--- chatwoot-adapter/multipart.ts | 4 +++- faq-bot/cooldown.ts | 21 ++++++++++++++++++++ faq-bot/index.test.ts | 3 ++- faq-bot/index.ts | 22 ++------------------- http-action/cooldown.ts | 21 ++++++++++++++++++++ http-action/reliability.ts | 17 +++------------- scripts/shared-copies.test.mjs | 23 ++++++++++++++++++++++ typebot-connector/chat-lock.ts | 2 ++ typebot-connector/multipart.ts | 2 ++ voice-transcription/multipart.ts | 33 +++++++++++++++----------------- 14 files changed, 123 insertions(+), 78 deletions(-) create mode 100644 after-hours/cooldown.ts create mode 100644 faq-bot/cooldown.ts create mode 100644 http-action/cooldown.ts create mode 100644 scripts/shared-copies.test.mjs diff --git a/after-hours/cooldown.ts b/after-hours/cooldown.ts new file mode 100644 index 0000000..f6c1dc2 --- /dev/null +++ b/after-hours/cooldown.ts @@ -0,0 +1,21 @@ +// In-memory per-key cooldown with an LRU cap. Pure — no ctx. +// NOTE: intentionally duplicated per plugin (plugins ship as self-contained zips) — keep all copies in +// sync; scripts/shared-copies.test.mjs fails the build when they drift. + +const MAX_COOLDOWN_ENTRIES = 5000; + +/** + * Decide whether an action may go to `key` now. On allow, records `nowMs` (re-inserting so the map + * evicts least-recently-used) and caps the map by dropping the LRU entry. A `cooldownMs` of 0 always allows. + */ +export function allowCooldown(map: Map, key: string, nowMs: number, cooldownMs: number): boolean { + const last = map.get(key); + if (last !== undefined && nowMs - last < cooldownMs) return false; + map.delete(key); // re-insert so iteration order tracks recency (LRU by touch) + map.set(key, nowMs); + if (map.size > MAX_COOLDOWN_ENTRIES) { + const oldest = map.keys().next().value as string | undefined; + if (oldest !== undefined) map.delete(oldest); + } + return true; +} diff --git a/after-hours/index.test.ts b/after-hours/index.test.ts index fa7eb59..f50130c 100644 --- a/after-hours/index.test.ts +++ b/after-hours/index.test.ts @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { parseConfig, allowReply } from './index.ts'; +import { parseConfig } from './index.ts'; +import { allowCooldown as allowReply } from './cooldown.ts'; const schedule = JSON.stringify({ mon: '09:00-17:00', sun: null }); diff --git a/after-hours/index.ts b/after-hours/index.ts index e426c15..21367fb 100644 --- a/after-hours/index.ts +++ b/after-hours/index.ts @@ -1,8 +1,6 @@ import type { IPlugin, PluginContext, HookContext, IncomingMessage } from '../types/openwa'; import { parseSchedule, assertValidTimezone, isAfterHours, Schedule } from './schedule.ts'; - -/** Cap on the per-chat cooldown map (drop oldest past this) so it can't grow unbounded. */ -const MAX_COOLDOWN_ENTRIES = 5000; +import { allowCooldown } from './cooldown.ts'; export interface AfterHoursConfig { timezone: string; @@ -40,22 +38,6 @@ export function parseConfig(raw: Record): { config: AfterHoursC }; } -/** - * Decide whether an after-hours reply may go to `key` now. On allow, records `nowMs` (re-inserting so - * the map evicts least-recently-used) and caps the map by dropping the LRU entry. `cooldownMs` of 0 always allows. - */ -export function allowReply(map: Map, key: string, nowMs: number, cooldownMs: number): boolean { - const last = map.get(key); - if (last !== undefined && nowMs - last < cooldownMs) return false; - map.delete(key); // re-insert so iteration order tracks recency (LRU by touch) - map.set(key, nowMs); - if (map.size > MAX_COOLDOWN_ENTRIES) { - const oldest = map.keys().next().value as string | undefined; - if (oldest !== undefined) map.delete(oldest); - } - return true; -} - export default class AfterHours implements IPlugin { private readonly repliedAt = new Map(); @@ -92,7 +74,7 @@ export default class AfterHours implements IPlugin { const sessionId = hook.sessionId; const key = `${sessionId}:${m.chatId}`; const cooldownMs = Math.max(0, cfg.config.cooldownSec) * 1000; - if (!allowReply(this.repliedAt, key, Date.now(), cooldownMs)) return; + if (!allowCooldown(this.repliedAt, key, Date.now(), cooldownMs)) return; try { await ctx.messages.reply(sessionId, m.chatId, m.id, cfg.config.awayMessage); diff --git a/chatwoot-adapter/chat-lock.ts b/chatwoot-adapter/chat-lock.ts index 7908830..f1e6cd2 100644 --- a/chatwoot-adapter/chat-lock.ts +++ b/chatwoot-adapter/chat-lock.ts @@ -1,7 +1,8 @@ // In-worker per-key async mutex: run(key, fn) chains on the key's tail so critical sections for the same -// key run one at a time, while different keys run concurrently. Used to serialize inbound + outbound work -// per `${sessionId}:${chatId}` so a cold-start burst can't create duplicate Chatwoot contacts. A rejecting -// section is isolated (the chain recovers), and the map entry is dropped once its tail settles. Pure — no ctx. +// key run one at a time, while different keys run concurrently. A rejecting section is isolated (the chain +// recovers), and the map entry is dropped once its tail settles. Pure — no ctx. +// NOTE: intentionally duplicated per plugin (plugins ship as self-contained zips) — keep all copies in +// sync; scripts/shared-copies.test.mjs fails the build when they drift. export class KeyedAsyncLock { private readonly tails = new Map>(); diff --git a/chatwoot-adapter/multipart.ts b/chatwoot-adapter/multipart.ts index 76336ac..e56de00 100644 --- a/chatwoot-adapter/multipart.ts +++ b/chatwoot-adapter/multipart.ts @@ -10,7 +10,9 @@ export interface MultipartFile { } // Assemble a multipart/form-data body as raw bytes so a binary attachment survives intact (a string body -// would be UTF-8 re-encoded and corrupt non-UTF-8 bytes). Ported from voice-transcription. Pure — no ctx. +// would be UTF-8 re-encoded and corrupt non-UTF-8 bytes). Pure — no ctx. +// NOTE: intentionally duplicated per plugin (plugins ship as self-contained zips) — keep all copies in +// sync; scripts/shared-copies.test.mjs fails the build when they drift. export function buildMultipartBody(boundary: string, fields: MultipartField[], files: MultipartFile[]): Buffer { const parts: Buffer[] = []; for (const f of fields) { diff --git a/faq-bot/cooldown.ts b/faq-bot/cooldown.ts new file mode 100644 index 0000000..f6c1dc2 --- /dev/null +++ b/faq-bot/cooldown.ts @@ -0,0 +1,21 @@ +// In-memory per-key cooldown with an LRU cap. Pure — no ctx. +// NOTE: intentionally duplicated per plugin (plugins ship as self-contained zips) — keep all copies in +// sync; scripts/shared-copies.test.mjs fails the build when they drift. + +const MAX_COOLDOWN_ENTRIES = 5000; + +/** + * Decide whether an action may go to `key` now. On allow, records `nowMs` (re-inserting so the map + * evicts least-recently-used) and caps the map by dropping the LRU entry. A `cooldownMs` of 0 always allows. + */ +export function allowCooldown(map: Map, key: string, nowMs: number, cooldownMs: number): boolean { + const last = map.get(key); + if (last !== undefined && nowMs - last < cooldownMs) return false; + map.delete(key); // re-insert so iteration order tracks recency (LRU by touch) + map.set(key, nowMs); + if (map.size > MAX_COOLDOWN_ENTRIES) { + const oldest = map.keys().next().value as string | undefined; + if (oldest !== undefined) map.delete(oldest); + } + return true; +} diff --git a/faq-bot/index.test.ts b/faq-bot/index.test.ts index 544f80f..b904536 100644 --- a/faq-bot/index.test.ts +++ b/faq-bot/index.test.ts @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { parseConfig, allowFallback } from './index.ts'; +import { parseConfig } from './index.ts'; +import { allowCooldown as allowFallback } from './cooldown.ts'; const rules = JSON.stringify([{ mode: 'contains', pattern: 'hi', reply: 'hello' }]); diff --git a/faq-bot/index.ts b/faq-bot/index.ts index 79c74ef..e6d60b5 100644 --- a/faq-bot/index.ts +++ b/faq-bot/index.ts @@ -1,8 +1,6 @@ import type { IPlugin, PluginContext, HookContext, IncomingMessage } from '../types/openwa'; import { parseRules, matchRule, CompiledRule } from './rules.ts'; - -/** Cap on the per-chat fallback-cooldown map (drop oldest past this) so it can't grow unbounded. */ -const MAX_COOLDOWN_ENTRIES = 5000; +import { allowCooldown } from './cooldown.ts'; export interface FaqConfig { fallbackReply: string; @@ -41,22 +39,6 @@ export function parseConfig(raw: Record): { }; } -/** - * Decide whether a fallback may be sent to `key` now. On allow, records `nowMs` (re-inserting so the - * map evicts least-recently-used) and caps the map by dropping the LRU entry. A `cooldownMs` of 0 always allows. - */ -export function allowFallback(map: Map, key: string, nowMs: number, cooldownMs: number): boolean { - const last = map.get(key); - if (last !== undefined && nowMs - last < cooldownMs) return false; - map.delete(key); // re-insert so iteration order tracks recency (LRU by touch) - map.set(key, nowMs); - if (map.size > MAX_COOLDOWN_ENTRIES) { - const oldest = map.keys().next().value as string | undefined; - if (oldest !== undefined) map.delete(oldest); - } - return true; -} - export default class FaqBot implements IPlugin { private readonly fallbackAt = new Map(); @@ -106,7 +88,7 @@ export default class FaqBot implements IPlugin { if (cfg.config.fallbackReply) { const key = `${sessionId}:${m.chatId}`; const cooldownMs = Math.max(0, cfg.config.fallbackCooldownSec) * 1000; - if (allowFallback(this.fallbackAt, key, Date.now(), cooldownMs)) { + if (allowCooldown(this.fallbackAt, key, Date.now(), cooldownMs)) { await ctx.messages.reply(sessionId, m.chatId, m.id, cfg.config.fallbackReply); } } diff --git a/http-action/cooldown.ts b/http-action/cooldown.ts new file mode 100644 index 0000000..f6c1dc2 --- /dev/null +++ b/http-action/cooldown.ts @@ -0,0 +1,21 @@ +// In-memory per-key cooldown with an LRU cap. Pure — no ctx. +// NOTE: intentionally duplicated per plugin (plugins ship as self-contained zips) — keep all copies in +// sync; scripts/shared-copies.test.mjs fails the build when they drift. + +const MAX_COOLDOWN_ENTRIES = 5000; + +/** + * Decide whether an action may go to `key` now. On allow, records `nowMs` (re-inserting so the map + * evicts least-recently-used) and caps the map by dropping the LRU entry. A `cooldownMs` of 0 always allows. + */ +export function allowCooldown(map: Map, key: string, nowMs: number, cooldownMs: number): boolean { + const last = map.get(key); + if (last !== undefined && nowMs - last < cooldownMs) return false; + map.delete(key); // re-insert so iteration order tracks recency (LRU by touch) + map.set(key, nowMs); + if (map.size > MAX_COOLDOWN_ENTRIES) { + const oldest = map.keys().next().value as string | undefined; + if (oldest !== undefined) map.delete(oldest); + } + return true; +} diff --git a/http-action/reliability.ts b/http-action/reliability.ts index 5f3e128..39784b8 100644 --- a/http-action/reliability.ts +++ b/http-action/reliability.ts @@ -95,17 +95,6 @@ export async function prune( return { ran: true, pruned }; } -const MAX_COOLDOWN_ENTRIES = 5000; - -/** In-memory per-key cooldown, LRU-capped. True if allowed now (and records the touch); false if within the window. */ -export function allowCooldown(map: Map, key: string, nowMs: number, cooldownMs: number): boolean { - const last = map.get(key); - if (last !== undefined && nowMs - last < cooldownMs) return false; - map.delete(key); // re-insert so iteration order tracks recency (LRU by touch) - map.set(key, nowMs); - if (map.size > MAX_COOLDOWN_ENTRIES) { - const oldest = map.keys().next().value as string | undefined; - if (oldest !== undefined) map.delete(oldest); - } - return true; -} +// The in-memory per-key cooldown lives in ./cooldown.ts (shared copy); re-exported here so existing +// imports from './reliability.ts' keep working. +export { allowCooldown } from './cooldown.ts'; diff --git a/scripts/shared-copies.test.mjs b/scripts/shared-copies.test.mjs new file mode 100644 index 0000000..9aa21c1 --- /dev/null +++ b/scripts/shared-copies.test.mjs @@ -0,0 +1,23 @@ +// Drift guard for intentionally duplicated helper files. Plugins ship as self-contained zips, so +// shared helpers are copied per plugin instead of imported from a shared package — the price of that +// self-containment is that a fix in one copy does not reach the others. This test fails the suite +// the moment copies drift, forcing the fix to be applied everywhere (or the group list to be updated +// deliberately). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const GROUPS = [ + ['chatwoot-adapter/chat-lock.ts', 'typebot-connector/chat-lock.ts'], + ['chatwoot-adapter/multipart.ts', 'typebot-connector/multipart.ts', 'voice-transcription/multipart.ts'], + ['after-hours/cooldown.ts', 'faq-bot/cooldown.ts', 'http-action/cooldown.ts'], +]; + +for (const group of GROUPS) { + test(`shared copies stay in sync: ${group.join(', ')}`, () => { + const [first, ...rest] = group.map((p) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8')); + for (let i = 0; i < rest.length; i++) { + assert.equal(rest[i], first, `${group[i + 1]} has drifted from ${group[0]} — apply the change to every copy`); + } + }); +} diff --git a/typebot-connector/chat-lock.ts b/typebot-connector/chat-lock.ts index ee89f91..f1e6cd2 100644 --- a/typebot-connector/chat-lock.ts +++ b/typebot-connector/chat-lock.ts @@ -1,6 +1,8 @@ // In-worker per-key async mutex: run(key, fn) chains on the key's tail so critical sections for the same // key run one at a time, while different keys run concurrently. A rejecting section is isolated (the chain // recovers), and the map entry is dropped once its tail settles. Pure — no ctx. +// NOTE: intentionally duplicated per plugin (plugins ship as self-contained zips) — keep all copies in +// sync; scripts/shared-copies.test.mjs fails the build when they drift. export class KeyedAsyncLock { private readonly tails = new Map>(); diff --git a/typebot-connector/multipart.ts b/typebot-connector/multipart.ts index f9a9369..e56de00 100644 --- a/typebot-connector/multipart.ts +++ b/typebot-connector/multipart.ts @@ -11,6 +11,8 @@ export interface MultipartFile { // Assemble a multipart/form-data body as raw bytes so a binary attachment survives intact (a string body // would be UTF-8 re-encoded and corrupt non-UTF-8 bytes). Pure — no ctx. +// NOTE: intentionally duplicated per plugin (plugins ship as self-contained zips) — keep all copies in +// sync; scripts/shared-copies.test.mjs fails the build when they drift. export function buildMultipartBody(boundary: string, fields: MultipartField[], files: MultipartFile[]): Buffer { const parts: Buffer[] = []; for (const f of fields) { diff --git a/voice-transcription/multipart.ts b/voice-transcription/multipart.ts index fe86457..e56de00 100644 --- a/voice-transcription/multipart.ts +++ b/voice-transcription/multipart.ts @@ -2,37 +2,34 @@ export interface MultipartField { name: string; value: string; } - -export interface MultipartFilePart { +export interface MultipartFile { name: string; filename: string; contentType: string; data: Uint8Array; } -/** - * Assemble a multipart/form-data request body as a Buffer. Binary file parts are concatenated as raw - * bytes (never string-encoded), so audio survives intact across the sandbox→host fetch boundary. - */ -export function buildMultipartBody( - boundary: string, - fields: MultipartField[], - files: MultipartFilePart[], -): Buffer { +// Assemble a multipart/form-data body as raw bytes so a binary attachment survives intact (a string body +// would be UTF-8 re-encoded and corrupt non-UTF-8 bytes). Pure — no ctx. +// NOTE: intentionally duplicated per plugin (plugins ship as self-contained zips) — keep all copies in +// sync; scripts/shared-copies.test.mjs fails the build when they drift. +export function buildMultipartBody(boundary: string, fields: MultipartField[], files: MultipartFile[]): Buffer { const parts: Buffer[] = []; - for (const field of fields) { - parts.push( - Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field.name}"\r\n\r\n${field.value}\r\n`), - ); + for (const f of fields) { + parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${f.name}"\r\n\r\n${f.value}\r\n`)); } for (const file of files) { + // filename + contentType come from attacker-controlled WhatsApp media metadata. Strip CR/LF (and a `"` + // from the filename) so they can't break out of the part headers and inject extra multipart parts. + const filename = file.filename.replace(/[\r\n"]/g, ''); + const contentType = file.contentType.replace(/[\r\n]/g, ''); parts.push( Buffer.from( - `--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; filename="${file.filename}"\r\n` + - `Content-Type: ${file.contentType}\r\n\r\n`, + `--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; filename="${filename}"\r\n` + + `Content-Type: ${contentType}\r\n\r\n`, ), ); - parts.push(Buffer.from(file.data)); // raw bytes — never string-encoded + parts.push(Buffer.from(file.data)); parts.push(Buffer.from('\r\n')); } parts.push(Buffer.from(`--${boundary}--\r\n`)); From 6be675ffe335a1d83dd79e7dca7bf6ded4fb6111 Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Thu, 23 Jul 2026 16:25:12 +0700 Subject: [PATCH 5/9] feat(supabase-otp-hook): resolve canonical chat id before sending (v0.2.0) The OTP target chat id was built straight from the phone number (@c.us), which can miss contacts addressed by a privacy id. The handler now resolves the canonical chat id via engine.canonicalChatId when the host provides it (engine:read), bounded by a 2 s timeout so a wedged engine bridge falls back to the phone-derived id instead of stalling the ingress job into a retry and a duplicate OTP. --- supabase-otp-hook/CHANGELOG.md | 9 +++++++ supabase-otp-hook/README.md | 4 ++-- supabase-otp-hook/handler.test.ts | 39 +++++++++++++++++++++++++++++++ supabase-otp-hook/handler.ts | 38 ++++++++++++++++++++++++++---- supabase-otp-hook/index.ts | 2 ++ supabase-otp-hook/manifest.json | 22 +++++++++++++++-- 6 files changed, 105 insertions(+), 9 deletions(-) diff --git a/supabase-otp-hook/CHANGELOG.md b/supabase-otp-hook/CHANGELOG.md index 4d7e62f..d36cafb 100644 --- a/supabase-otp-hook/CHANGELOG.md +++ b/supabase-otp-hook/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this plugin are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.2.0] — 2026-07-23 + +### Added + +- Resolve the phone-derived `@c.us` chat id through `ctx.engine.canonicalChatId` (OpenWA + 0.8.7+, new `engine:read` permission) before sending, so OTPs still land in the right chat for + contacts keyed by a `@lid` privacy id. Best-effort: on older hosts or a resolution failure the + phone JID is used unchanged. + ## [0.1.0] — 2026-07-07 ### Added diff --git a/supabase-otp-hook/README.md b/supabase-otp-hook/README.md index 16aa65f..b65e085 100644 --- a/supabase-otp-hook/README.md +++ b/supabase-otp-hook/README.md @@ -15,8 +15,8 @@ | Field | Value | | ----- | ----- | | **Identifier** | `supabase-otp-hook` | -| **Version** | 0.1.0 | -| **Released** | 2026-07-07 | +| **Version** | 0.2.0 | +| **Released** | 2026-07-23 | | **Status** | beta | | **Author** | maplerichie | | **License** | MIT | diff --git a/supabase-otp-hook/handler.test.ts b/supabase-otp-hook/handler.test.ts index 386cb6f..b7c5e97 100644 --- a/supabase-otp-hook/handler.test.ts +++ b/supabase-otp-hook/handler.test.ts @@ -89,6 +89,45 @@ test('prefers req.sessionId over fallbackSessionId', async () => { assert.equal(sent[0].chatId, '15559876543@c.us'); }); +// ── canonical chat id (LID migration) ──────────────────────────────────────── + +test('uses canonicalChatId when provided (sends to the resolved id, not the phone JID)', async () => { + const { sent, deps } = makeDeps(); + const calls: Array<[string, string]> = []; + const canonicalChatId = async (sessionId: string, chatId: string) => { + calls.push([sessionId, chatId]); + return '99999@lid'; + }; + await handleSendSms( + { ...deps, canonicalChatId }, + makeReq({ user: { phone: '+15551234567' }, sms: { otp: '123456' } }, { sessionId: 'sess-1' }), + ); + assert.deepEqual(calls, [['sess-1', '15551234567@c.us']]); + assert.equal(sent[0].chatId, '99999@lid'); +}); + +test('falls back to the phone-derived JID when canonicalChatId throws', async () => { + const { sent, deps } = makeDeps(); + const canonicalChatId = async () => { throw new Error('engine unavailable'); }; + await handleSendSms( + { ...deps, canonicalChatId }, + makeReq({ user: { phone: '+15551234567' }, sms: { otp: '123456' } }, { sessionId: 'sess-1' }), + ); + assert.equal(sent[0].chatId, '15551234567@c.us'); +}); + +test('falls back to the phone-derived JID when canonicalChatId hangs (>2 s bridge timeout)', async () => { + const { sent, deps } = makeDeps(); + const canonicalChatId = () => new Promise(() => {}); // never settles + const start = Date.now(); + await handleSendSms( + { ...deps, canonicalChatId }, + makeReq({ user: { phone: '+15551234567' }, sms: { otp: '123456' } }, { sessionId: 'sess-1' }), + ); + assert.equal(sent[0].chatId, '15551234567@c.us'); + assert.ok(Date.now() - start < 2900, 'must fall back after the 2 s timeout, not stall the ingress job'); +}); + // ── validation / no-send paths (return → no retry) ─────────────────────────── // Signature verification and the session-liveness check are host-side; these cover the payload-level // permanent failures the handler itself rejects (by returning, so the host does not retry/DLQ them). diff --git a/supabase-otp-hook/handler.ts b/supabase-otp-hook/handler.ts index 8e8bf08..c6e0297 100644 --- a/supabase-otp-hook/handler.ts +++ b/supabase-otp-hook/handler.ts @@ -28,6 +28,13 @@ export interface HandlerDeps { config: SupabaseSmsConfig; messages: Pick; log: (message: string, meta?: Record) => void; + /** + * Optional `ctx.engine.canonicalChatId` (OpenWA 0.8.7+). When present, the phone-derived + * `@c.us` chat id is resolved to the session's canonical id before sending, so an OTP + * still lands in the right chat when the contact is keyed by a `@lid` privacy id. Absent (older + * host or no engine:read permission) the raw phone JID is used unchanged. + */ + canonicalChatId?: (sessionId: string, chatId: string) => Promise; } interface SupabaseSmsPayload { @@ -87,26 +94,47 @@ export async function handleSendSms(deps: HandlerDeps, req: WebhookRequest): Pro return; } + // Resolve to the canonical chat id when the host supports it (best-effort: any failure keeps the + // phone-derived JID). See HandlerDeps.canonicalChatId. Bounded to 2 s: this is the ONLY await before + // the fire-and-forget send, and a wedged engine bridge must not stall the ingress job into a + // timeout → retry → duplicate OTP. + let targetChatId = chatId; + if (deps.canonicalChatId) { + let timer: ReturnType | undefined; + try { + targetChatId = await Promise.race([ + deps.canonicalChatId(sessionId, chatId), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('canonicalChatId timeout')), 2000); + }), + ]); + } catch { + /* keep the phone-derived JID */ + } finally { + clearTimeout(timer); + } + } + if (cfg.debug) { deps.log('supabase-otp-hook: inbound delivery', { debug: true, instanceId: req.instanceId, deliveryId: req.deliveryId, sessionId, - chatId, + chatId: targetChatId, }); } const text = composeMessage(cfg.messageTemplate, otp, cfg.appName); - if (cfg.debug) deps.log('supabase-otp-hook: sending OTP', { debug: true, sessionId, chatId, text }); + if (cfg.debug) deps.log('supabase-otp-hook: sending OTP', { debug: true, sessionId, chatId: targetChatId, text }); // Fire-and-forget: the worker dispatch is bounded to 5 s (INGRESS_DISPATCH_TIMEOUT_MS), so awaiting a // slow send risks a 504 → retry → DUPLICATE OTP. Background it; failures are logged, not retried. - void deps.messages.sendText(sessionId, chatId, text).then( - () => { if (cfg.debug) deps.log('supabase-otp-hook: sendText ok', { debug: true, sessionId, chatId }); }, + void deps.messages.sendText(sessionId, targetChatId, text).then( + () => { if (cfg.debug) deps.log('supabase-otp-hook: sendText ok', { debug: true, sessionId, chatId: targetChatId }); }, (err: unknown) => { deps.log('supabase-otp-hook: sendText failed (background)', { - sessionId, chatId, error: err instanceof Error ? err.message : String(err), + sessionId, chatId: targetChatId, error: err instanceof Error ? err.message : String(err), }); }, ); diff --git a/supabase-otp-hook/index.ts b/supabase-otp-hook/index.ts index 37a213e..db57627 100644 --- a/supabase-otp-hook/index.ts +++ b/supabase-otp-hook/index.ts @@ -26,6 +26,8 @@ export default class SupabaseSmsHook implements IPlugin { config, messages: ctx.messages, log: (m, meta) => ctx.logger.warn(m, meta), + // Best-effort LID→phone resolution (0.8.7+, engine:read). Absent on older hosts. + canonicalChatId: ctx.engine?.canonicalChatId?.bind(ctx.engine), }, req, ); diff --git a/supabase-otp-hook/manifest.json b/supabase-otp-hook/manifest.json index 4fe0a4b..3956cc6 100644 --- a/supabase-otp-hook/manifest.json +++ b/supabase-otp-hook/manifest.json @@ -1,7 +1,7 @@ { "id": "supabase-otp-hook", "name": "Supabase Auth OTP", - "version": "0.1.0", + "version": "0.2.0", "type": "extension", "main": "dist/index.js", "description": "Deliver Supabase Auth phone OTPs over WhatsApp.", @@ -14,7 +14,7 @@ "minOpenWAVersion": "0.8.16", "testedOpenWAVersion": "0.8.16", "sdkVersion": "1", - "permissions": ["webhook:ingress", "messages:send"], + "permissions": ["webhook:ingress", "messages:send", "engine:read"], "sessionScoped": true, "sessions": ["*"], "ingress": [ @@ -65,5 +65,23 @@ "description": "Log every inbound request (resolved session + chatId), each outbound send, and send failures. Never blocks or skips WhatsApp sending. Signature verification and the session-liveness check happen host-side before this handler runs, so they are not logged here." } } + }, + "i18n": { + "es": { "name": "OTP de Supabase Auth", "description": "Entrega los OTP telefónicos de Supabase Auth a través de WhatsApp.", + "config": { "appName": { "title": "Nombre de la app" }, "messageTemplate": { "title": "Plantilla de mensaje" }, "fallbackSessionId": { "title": "Sesión de WhatsApp de respaldo" }, "debug": { "title": "Modo de depuración" } } }, + "fr": { "name": "OTP Supabase Auth", "description": "Envoie les OTP téléphoniques de Supabase Auth via WhatsApp.", + "config": { "appName": { "title": "Nom de l'application" }, "messageTemplate": { "title": "Modèle de message" }, "fallbackSessionId": { "title": "Session WhatsApp de secours" }, "debug": { "title": "Mode débogage" } } }, + "it": { "name": "OTP di Supabase Auth", "description": "Invia gli OTP telefonici di Supabase Auth tramite WhatsApp.", + "config": { "appName": { "title": "Nome dell'app" }, "messageTemplate": { "title": "Modello di messaggio" }, "fallbackSessionId": { "title": "Sessione WhatsApp di riserva" }, "debug": { "title": "Modalità debug" } } }, + "ar": { "name": "OTP لـ Supabase Auth", "description": "يُسلّم رموز OTP الهاتفية من Supabase Auth عبر WhatsApp.", + "config": { "appName": { "title": "اسم التطبيق" }, "messageTemplate": { "title": "قالب الرسالة" }, "fallbackSessionId": { "title": "جلسة WhatsApp الاحتياطية" }, "debug": { "title": "وضع التصحيح" } } }, + "he": { "name": "OTP של Supabase Auth", "description": "מספק קודי OTP טלפוניים של Supabase Auth דרך WhatsApp.", + "config": { "appName": { "title": "שם האפליקציה" }, "messageTemplate": { "title": "תבנית הודעה" }, "fallbackSessionId": { "title": "סשן WhatsApp גיבוי" }, "debug": { "title": "מצב ניפוי שגיאות" } } }, + "te": { "name": "Supabase Auth OTP", "description": "Supabase Auth ఫోన్ OTPలను WhatsApp ద్వారా డెలివర్ చేస్తుంది.", + "config": { "appName": { "title": "యాప్ పేరు" }, "messageTemplate": { "title": "సందేశ టెంప్లేట్" }, "fallbackSessionId": { "title": "ఫాల్‌బ్యాక్ WhatsApp సెషన్" }, "debug": { "title": "డీబగ్ మోడ్" } } }, + "zh-CN": { "name": "Supabase Auth OTP", "description": "通过 WhatsApp 发送 Supabase Auth 手机 OTP 验证码。", + "config": { "appName": { "title": "应用名称" }, "messageTemplate": { "title": "消息模板" }, "fallbackSessionId": { "title": "备用 WhatsApp 会话" }, "debug": { "title": "调试模式" } } }, + "zh-HK": { "name": "Supabase Auth OTP", "description": "透過 WhatsApp 發送 Supabase Auth 手機 OTP 驗證碼。", + "config": { "appName": { "title": "應用程式名稱" }, "messageTemplate": { "title": "訊息範本" }, "fallbackSessionId": { "title": "備用 WhatsApp 工作階段" }, "debug": { "title": "除錯模式" } } } } } From 019182fb062520a25f38d8b6161ff48128f91d3e Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Thu, 23 Jul 2026 16:25:12 +0700 Subject: [PATCH 6/9] feat(gsheets-logger): route outbound HTTP through ctx.net.fetch (v0.3.0) SheetsClient now takes an injected NetFetch bound to ctx.net.fetch (host-proxied, SSRF-guarded) instead of the raw worker fetch, bringing the plugin inside the v0.7 net contract: permissions [net:fetch] with net.allow pinned to the two fixed Google hosts, minOpenWAVersion 0.7.0. Responses are read via res.body per the sandbox contract. Client tests now cover token caching, re-authentication after a 401, and malformed token responses. --- gsheets-logger/CHANGELOG.md | 6 ++- gsheets-logger/README.md | 20 ++++---- gsheets-logger/index.test.ts | 6 ++- gsheets-logger/index.ts | 6 ++- gsheets-logger/manifest.json | 8 ++-- gsheets-logger/sheets-client.test.ts | 69 +++++++++++++++++++++++++++- gsheets-logger/sheets-client.ts | 21 ++++++--- 7 files changed, 114 insertions(+), 22 deletions(-) diff --git a/gsheets-logger/CHANGELOG.md b/gsheets-logger/CHANGELOG.md index 5f10ce4..2dcc4e9 100644 --- a/gsheets-logger/CHANGELOG.md +++ b/gsheets-logger/CHANGELOG.md @@ -6,10 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this plugin adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The version here always matches `manifest.json`'s `version`. -## [Unreleased] +## [0.3.0] — 2026-07-23 ### Changed +- **Outbound HTTP now goes through `ctx.net.fetch`** (host-proxied, SSRF-guarded) instead of the raw + worker `fetch`, bringing the plugin back inside the v0.7 net contract. The manifest declares + `permissions: ["net:fetch"]` and `net.allow: ["oauth2.googleapis.com", "sheets.googleapis.com"]` — + the only two hosts the plugin ever calls — and `minOpenWAVersion` rises to **0.7.0** accordingly. - **Documented that per-session spreadsheet routing is not supported.** The plugin holds one buffer and one Sheets client built from the base `*` config, so a per-session config override that points at a different `spreadsheetId` / `serviceAccountJson` is **not** honored — rows from every session land in diff --git a/gsheets-logger/README.md b/gsheets-logger/README.md index 48cf287..e6d78f2 100644 --- a/gsheets-logger/README.md +++ b/gsheets-logger/README.md @@ -5,7 +5,7 @@ ![type: extension](https://img.shields.io/badge/type-extension-blue.svg) ![license: MIT](https://img.shields.io/badge/license-MIT-green.svg) -![built for OpenWA](https://img.shields.io/badge/OpenWA-%E2%89%A5%200.6.1-25D366.svg) +![built for OpenWA](https://img.shields.io/badge/OpenWA-%E2%89%A5%200.7.0-25D366.svg) [![downloads](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Frmyndharis%2FOpenWA-plugins%2Fbadges%2Fdownloads%2Fgsheets-logger.json)](https://github.com/rmyndharis/OpenWA-plugins/releases?q=gsheets-logger) ## Details @@ -14,13 +14,13 @@ | Field | Value | | ----- | ----- | | **Identifier** | `gsheets-logger` | -| **Version** | 0.2.3 | -| **Released** | 2026-07-02 | +| **Version** | 0.3.0 | +| **Released** | 2026-07-23 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | | **Type** | `extension` | -| **Requires OpenWA** | ≥ 0.6.1 (tested 0.8.1) | +| **Requires OpenWA** | ≥ 0.7.0 (tested 0.8.1) | | **Keywords** | google-sheets, logging, audit, crm, whatsapp, openwa | | **Repository** | [OpenWA-plugins/gsheets-logger](https://github.com/rmyndharis/OpenWA-plugins/tree/main/gsheets-logger) | @@ -39,8 +39,10 @@ The installed version is also visible in the OpenWA dashboard Plugins list (`v0. dependencies**, so the package is tiny. - **Formula-injection safe** — writes with `valueInputOption=RAW` and neutralizes CSV/Sheets formula triggers, while leaving legitimate `+`/`-` content (phone numbers) intact in free-text cells. -- **Least privilege** — declares no capabilities (`permissions: []`); it only reads hook events and - writes to your sheet, never sends messages or reads contacts. +- **Least privilege** — declares only `net:fetch`, allowlisted to the two fixed Google hosts + (`oauth2.googleapis.com`, `sheets.googleapis.com`) via `net.allow`; all outbound HTTP goes through the + host-proxied, SSRF-guarded `ctx.net.fetch`. It only reads hook events and writes to your sheet, + never sends messages or reads contacts. - **Self-reporting version** — surfaces its own version in logs and `healthCheck`. ## What it logs @@ -200,8 +202,10 @@ The target tab must exist, **with a header row of your choosing** — the plugin ## Compatibility -External plugins run **sandboxed in a worker thread** (since OpenWA **v0.6.0**). Two capabilities are -version-dependent: +External plugins run **sandboxed in a worker thread** (since OpenWA **v0.6.0**). Requires OpenWA +**≥ 0.7.0** — all outbound HTTP (OAuth token + Sheets append) goes through the host-proxied, +SSRF-guarded `ctx.net.fetch` introduced in v0.7, allowlisted to the two fixed Google hosts. Two further +capabilities are version-dependent: - **`message:ack` rows** require OpenWA **≥ v0.6.1** (#427). On v0.6.0 the hook was declared but never fired, so ack rows are absent. diff --git a/gsheets-logger/index.test.ts b/gsheets-logger/index.test.ts index 425e86f..d1a84db 100644 --- a/gsheets-logger/index.test.ts +++ b/gsheets-logger/index.test.ts @@ -110,7 +110,11 @@ test('onConfigChange drains the buffer to the old client before swapping', async serviceAccountJson: JSON.stringify({ client_email: 'a@b.iam.gserviceaccount.com', private_key: 'KEY' }), spreadsheetId: 'NEW_SHEET', }; - await logger.onConfigChange({ config: newConfig } as unknown as never, newConfig); + const fakeCtx = { + config: newConfig, + net: { fetch: async () => ({ ok: true, status: 200, body: '{}' }) }, + }; + await logger.onConfigChange(fakeCtx as unknown as never, newConfig); await logger.onUnload(); // stop the interval started by onConfigChange assert.deepEqual(sentToOld, [['old-row']]); // buffered row went to the OLD client diff --git a/gsheets-logger/index.ts b/gsheets-logger/index.ts index 8fdf4a2..190541a 100644 --- a/gsheets-logger/index.ts +++ b/gsheets-logger/index.ts @@ -77,7 +77,9 @@ export default class GSheetsLogger implements IPlugin { async onEnable(ctx: PluginContext): Promise { this.ctx = ctx; const { config, sa } = parseConfig(ctx.config); - this.client = new SheetsClient(sa, config.spreadsheetId, config.sheetTab); + // Outbound HTTP goes through ctx.net.fetch (host-proxied, SSRF-guarded, net:fetch permission + + // manifest net.allow for the two fixed Google hosts) — never the raw unguarded worker fetch. + this.client = new SheetsClient(ctx.net.fetch.bind(ctx.net), sa, config.spreadsheetId, config.sheetTab); this.batchSize = config.flushBatchSize; const restored = await ctx.storage.get(BUFFER_KEY); @@ -99,7 +101,7 @@ export default class GSheetsLogger implements IPlugin { await this.flush(); this.ctx = ctx; const { config, sa } = parseConfig(ctx.config); - this.client = new SheetsClient(sa, config.spreadsheetId, config.sheetTab); + this.client = new SheetsClient(ctx.net.fetch.bind(ctx.net), sa, config.spreadsheetId, config.sheetTab); this.batchSize = config.flushBatchSize; this.startTimer(config.flushIntervalSec); } diff --git a/gsheets-logger/manifest.json b/gsheets-logger/manifest.json index f1383eb..860a383 100644 --- a/gsheets-logger/manifest.json +++ b/gsheets-logger/manifest.json @@ -1,7 +1,7 @@ { "id": "gsheets-logger", "name": "Google Sheets Logger", - "version": "0.2.3", + "version": "0.3.0", "type": "extension", "main": "dist/index.js", "description": "Logs WhatsApp message events to a Google Sheet via a service account.", @@ -11,10 +11,12 @@ "repository": "https://github.com/rmyndharis/OpenWA-plugins", "keywords": ["google-sheets", "logging", "audit", "crm", "whatsapp", "openwa"], "status": "stable", - "minOpenWAVersion": "0.6.1", + "minOpenWAVersion": "0.7.0", "testedOpenWAVersion": "0.8.1", "provides": ["message-logging"], - "permissions": [], + "permissions": ["net:fetch"], + "net": { "allow": ["oauth2.googleapis.com", "sheets.googleapis.com"] }, + "sessionScoped": true, "sessions": ["*"], "hooks": ["message:received", "message:sent", "message:failed", "message:ack"], "configSchema": { diff --git a/gsheets-logger/sheets-client.test.ts b/gsheets-logger/sheets-client.test.ts index 698fba4..d094bd0 100644 --- a/gsheets-logger/sheets-client.test.ts +++ b/gsheets-logger/sheets-client.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { generateKeyPairSync, createVerify } from 'node:crypto'; -import { buildJwt } from './sheets-client.ts'; +import { buildJwt, SheetsClient, type NetFetch } from './sheets-client.ts'; test('buildJwt produces a verifiable RS256 JWT with the right claims', () => { const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); @@ -22,3 +22,70 @@ test('buildJwt produces a verifiable RS256 JWT with the right claims', () => { const verified = createVerify('RSA-SHA256').update(`${h}.${c}`).verify(publicKey, sig, 'base64url'); assert.ok(verified, 'signature verifies against the public key'); }); + +// ── SheetsClient over an injected NetFetch (sandbox contract: res.body string, no .json()) ── + +const testSa = (() => { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + return { client_email: 'svc@proj.iam.gserviceaccount.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string }; +})(); + +interface FetchCall { url: string; init?: { method?: string; headers?: Record; body?: string | Uint8Array } } + +function fakeFetch(handlers: Record) { + const calls: FetchCall[] = []; + const fn: NetFetch = async (url, init) => { + calls.push({ url, init }); + const key = Object.keys(handlers).find((k) => url.startsWith(k)); + const h = key ? handlers[key] : { status: 404, body: 'not found' }; + return { ok: (h.status ?? 200) < 400, status: h.status ?? 200, body: h.body }; + }; + return { calls, fn }; +} + +const TOKEN_OK = { body: JSON.stringify({ access_token: 'tok-1', expires_in: 3600 }) }; + +test('appendRows fetches a token once, caches it, and posts rows as a string body', async () => { + const { calls, fn } = fakeFetch({ + 'https://oauth2.googleapis.com/token': TOKEN_OK, + 'https://sheets.googleapis.com/': { body: '{}' }, + }); + const client = new SheetsClient(fn, testSa, 'SHEET1', 'Logs'); + await client.appendRows([['a', 'b']]); + await client.appendRows([['c']]); + + const tokenCalls = calls.filter((c) => c.url.startsWith('https://oauth2.googleapis.com')); + assert.equal(tokenCalls.length, 1, 'token is cached across appends'); + assert.equal(typeof tokenCalls[0].init?.body, 'string', 'form body must be a string (sandbox encodes UTF-8)'); + assert.match(tokenCalls[0].init?.body as string, /grant_type=urn/); + assert.match(tokenCalls[0].init?.body as string, /assertion=/); + + const appends = calls.filter((c) => c.url.startsWith('https://sheets.googleapis.com')); + assert.equal(appends.length, 2); + assert.equal(appends[0].init?.headers?.authorization, 'Bearer tok-1'); + assert.match(appends[0].url, /spreadsheets\/SHEET1\/values\/Logs!A1:append/); + assert.deepEqual(JSON.parse(appends[1].init?.body as string), { values: [['c']] }); +}); + +test('a 401 clears the cached token so the next append re-authenticates', async () => { + let appendAttempts = 0; + const calls: FetchCall[] = []; + const fn: NetFetch = async (url, init) => { + calls.push({ url, init }); + if (url.startsWith('https://oauth2.googleapis.com')) return { ok: true, status: 200, body: TOKEN_OK.body }; + appendAttempts++; + return appendAttempts === 1 + ? { ok: false, status: 401, body: 'expired' } + : { ok: true, status: 200, body: '{}' }; + }; + const client = new SheetsClient(fn, testSa, 'SHEET1', 'Logs'); + await assert.rejects(client.appendRows([['a']]), /Append failed: 401/); + await client.appendRows([['a']]); + assert.equal(calls.filter((c) => c.url.startsWith('https://oauth2.googleapis.com')).length, 2, 're-authenticated after 401'); +}); + +test('a malformed token response throws instead of caching garbage', async () => { + const { fn } = fakeFetch({ 'https://oauth2.googleapis.com/token': { body: '{"nope":true}' } }); + const client = new SheetsClient(fn, testSa, 'SHEET1', 'Logs'); + await assert.rejects(client.appendRows([['a']]), /missing access_token\/expires_in/); +}); diff --git a/gsheets-logger/sheets-client.ts b/gsheets-logger/sheets-client.ts index 40072e1..12c3050 100644 --- a/gsheets-logger/sheets-client.ts +++ b/gsheets-logger/sheets-client.ts @@ -5,6 +5,14 @@ export interface ServiceAccount { private_key: string; } +// The slice of ctx.net.fetch this client needs (host-proxied, SSRF-guarded, gated by the net:fetch +// permission + manifest net.allow). Injectable so tests can fake it. Note the sandbox contract: +// there is NO working res.json()/res.text() — the body comes back as a UTF-8 string in `res.body`. +export type NetFetch = ( + url: string, + init?: { method?: string; headers?: Record; body?: string | Uint8Array }, +) => Promise<{ ok: boolean; status: number; body: string }>; + const TOKEN_URL = 'https://oauth2.googleapis.com/token'; const SCOPE = 'https://www.googleapis.com/auth/spreadsheets'; @@ -27,6 +35,7 @@ export class SheetsClient { private tokenExp = 0; // epoch seconds constructor( + private readonly fetch: NetFetch, private readonly sa: ServiceAccount, private readonly spreadsheetId: string, private readonly sheetTab: string, @@ -37,13 +46,13 @@ export class SheetsClient { if (this.token && now < this.tokenExp - 60) return this.token; const assertion = buildJwt(this.sa, now); - const res = await fetch(TOKEN_URL, { + const res = await this.fetch(TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }), + body: String(new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion })), }); - if (!res.ok) throw new Error(`Token request failed: ${res.status} ${await res.text()}`); - const json = (await res.json()) as { access_token?: unknown; expires_in?: unknown }; + if (!res.ok) throw new Error(`Token request failed: ${res.status} ${res.body.slice(0, 300)}`); + const json = JSON.parse(res.body || '{}') as { access_token?: unknown; expires_in?: unknown }; if (typeof json.access_token !== 'string' || typeof json.expires_in !== 'number') { throw new Error('Token response missing access_token/expires_in'); } @@ -58,14 +67,14 @@ export class SheetsClient { const range = encodeURIComponent(`${this.sheetTab}!A1`); const url = `https://sheets.googleapis.com/v4/spreadsheets/${this.spreadsheetId}/values/${range}:append` + `?valueInputOption=RAW&insertDataOption=INSERT_ROWS`; - const res = await fetch(url, { + const res = await this.fetch(url, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ values: rows }), }); if (!res.ok) { if (res.status === 401) this.token = null; // force a refresh on the next attempt - throw new Error(`Append failed: ${res.status} ${await res.text()}`); + throw new Error(`Append failed: ${res.status} ${res.body.slice(0, 300)}`); } } } From 94964ad7a8fa03af100eb40ea4972c89d31b5fe2 Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Thu, 23 Jul 2026 16:25:35 +0700 Subject: [PATCH 7/9] fix(chatwoot-adapter): serialize echo marker against the webhook path (v0.5.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter marked its own outgoing Chatwoot posts as seen only after the POST returned, but the message_created echo webhook could be processed inside that window — and the inbound/backfill paths hold a different per-chat lock than the webhook dedup, so nothing serialized the two. An echo landing in the gap was relayed to WhatsApp as a duplicate of a backfilled or mirrored message. The post and its marker write now hold a conversation-scoped lock that the webhook dedup also takes (innermost on every path, so no lock-order cycle). Includes a regression test that drives the echo into the window deterministically, plus README notes on mapping-storage growth. --- chatwoot-adapter/CHANGELOG.md | 12 ++++++++ chatwoot-adapter/README.md | 13 +++++++-- chatwoot-adapter/echo-loop.test.ts | 44 +++++++++++++++++++++++++++++- chatwoot-adapter/manifest.json | 20 +++++++++++++- chatwoot-adapter/outbound.ts | 6 +++- chatwoot-adapter/relay.ts | 6 ++++ 6 files changed, 96 insertions(+), 5 deletions(-) diff --git a/chatwoot-adapter/CHANGELOG.md b/chatwoot-adapter/CHANGELOG.md index f747119..d06bc5b 100644 --- a/chatwoot-adapter/CHANGELOG.md +++ b/chatwoot-adapter/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to the Chatwoot Adapter plugin are documented here. The form ## [Unreleased] +## [0.5.6] — 2026-07-23 + +### Fixed + +- **A backfilled or mirrored message could be delivered twice when Chatwoot's echo webhook arrived + mid-post.** The adapter marks its own `outgoing` posts as "seen" only after Chatwoot confirms the + post, but the `message_created` echo webhook can be processed before that confirmation returns — + and inbound/backfill hold a different per-chat lock than the webhook path, so nothing serialized + the two. The echo then looked unmarked and was relayed to WhatsApp as a duplicate. The post and its + echo-marker write now hold a conversation-scoped lock that the webhook dedup also takes, so the + echo always waits for the marker and is correctly skipped (regression test included). + ## [0.5.5] — 2026-07-22 ### Fixed diff --git a/chatwoot-adapter/README.md b/chatwoot-adapter/README.md index 11c9709..7ca4aaf 100644 --- a/chatwoot-adapter/README.md +++ b/chatwoot-adapter/README.md @@ -15,8 +15,8 @@ | Field | Value | | ----- | ----- | | **Identifier** | `chatwoot-adapter` | -| **Version** | 0.5.5 | -| **Released** | 2026-07-22 | +| **Version** | 0.5.6 | +| **Released** | 2026-07-23 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | @@ -167,6 +167,15 @@ instance id or route — so re-copy the ingress URL from the mint response. If you do end up with a duplicate, merging the two contacts in Chatwoot is safe. - **Chatwoot** — account-level webhooks with timestamped HMAC signing (see Setup). +### Known limitations + +- **Mapping storage grows with use (by design).** The `conv:`/`wa:` conversation-mapping entries in + plugin storage are never pruned — deleting a mapping would sever a live thread, and there is no safe + signal that a Chatwoot conversation will never be written to again. Growth is one small record per + WhatsApp chat ever relayed, so it stays modest in practice; `healthCheck` reports retry-queue and + dead-letter health if you need operational signals. The 3-day `seen:` dedup markers, by contrast, + expire and are pruned hourly. + ### Per-session config **Supported.** Every config field (`baseUrl`, `apiToken`, `accountId`, `inboxId`, `relayGroups`, diff --git a/chatwoot-adapter/echo-loop.test.ts b/chatwoot-adapter/echo-loop.test.ts index fd3e2f3..a8aac89 100644 --- a/chatwoot-adapter/echo-loop.test.ts +++ b/chatwoot-adapter/echo-loop.test.ts @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { handleSent } from './sent.ts'; import { handleOutbound, type OutboundDeps } from './outbound.ts'; -import type { InboundDeps } from './relay.ts'; +import { relayMessage, type InboundDeps } from './relay.ts'; import { KeyedAsyncLock } from './chat-lock.ts'; import { MappingStore } from './mapping-store.ts'; import type { @@ -177,3 +177,45 @@ test("one tenant's mirror marker does not suppress another tenant's reply with t assert.equal(sent.length, 1, "tenant B's genuine reply was suppressed by tenant A's echo marker"); assert.equal(sent[0].chatId, 'bob@c.us'); }); + +test('an echo webhook processed while the adapter post is still in flight is NOT re-sent (backfill race)', async () => { + // relayMessage marks the 'cw' echo marker AFTER the POST returns. Backfill holds the RAW chat lock + // while outbound.relay dedups under the CANONICAL lock — two different locks — so a Chatwoot + // message_created webhook processed inside that window (the webhook can arrive before the POST + // response does) would see no marker and re-send a backfilled message to the customer. + const store = new MappingStore(fakeStorage(), fakeMappings); + const lock = new KeyedAsyncLock(); + const engine = { canonicalChatId: async (_s: string, c: string) => c }; + await store.link('sess', CHAT_ID, 'inst', { + conversationId: CONVERSATION_ID, contactId: 9, sourceId: 'src', name: 'Budi', + }); + + let releasePost = (): void => {}; + const postGate = new Promise((resolve) => { releasePost = resolve; }); + const client = { + postText: async () => { await postGate; return { id: 4242 }; }, + postMedia: async () => { throw new Error('unused in this test'); }, + }; + const inbound = { + lock, client, store, engine, instanceId: 'inst', + relayGroups: true, relayMedia: true, backfillLimit: 0, backfillAllOnce: false, log: () => {}, + } as unknown as InboundDeps; + const sent: Array<{ chatId?: string }> = []; + const outbound = { + lock, store, engine, + conversations: { send: async (e: { chatId?: string }) => { sent.push(e); return { messageId: 'wa-r' }; } }, + handover: { set: async () => {} }, + inboxId: INBOX_ID, + log: () => {}, + } as unknown as OutboundDeps; + + // The relay runs with no shared lock held (the backfill case: raw-key lock, which outbound never + // takes), and the echo webhook is processed while the POST is still in flight. + const relayPromise = relayMessage(inbound, 'sess', CONVERSATION_ID, own, 'outgoing'); + const echoPromise = handleOutbound(outbound, mirrorWebhook(4242, 'sess')); + for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); // let the echo race ahead + releasePost(); + await Promise.all([relayPromise, echoPromise]); + + assert.deepEqual(sent, [], 'the echo was processed before the marker landed and got re-sent to WhatsApp'); +}); diff --git a/chatwoot-adapter/manifest.json b/chatwoot-adapter/manifest.json index 12e4d5c..c024642 100644 --- a/chatwoot-adapter/manifest.json +++ b/chatwoot-adapter/manifest.json @@ -1,7 +1,7 @@ { "id": "chatwoot-adapter", "name": "Chatwoot Adapter", - "version": "0.5.5", + "version": "0.5.6", "type": "extension", "main": "dist/index.js", "description": "Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker.", @@ -97,5 +97,23 @@ "description": "In addition to the per-conversation backfill, run a one-time sweep that imports the history of every existing chat. Needs 'History backfill' above > 0. Runs once per session; large inboxes may take a while." } } + }, + "i18n": { + "es": { "name": "Adaptador de Chatwoot", "description": "Sincronización bidireccional entre una sesión de WhatsApp y una bandeja de entrada de Chatwoot: reenvía los mensajes de WhatsApp (1:1 y grupos, con multimedia) a Chatwoot como una bandeja de canal API, devuelve las respuestas de los agentes a WhatsApp y transfiere el chat a un agente humano —silenciando a los demás bots de OpenWA— cuando un agente lo toma en Chatwoot. Primer consumidor del OpenWA Integration SDK v1; se ejecuta en sandbox dentro del worker de plugins.", + "config": { "baseUrl": { "title": "URL base de Chatwoot" }, "apiToken": { "title": "Token de acceso a la API" }, "accountId": { "title": "ID de cuenta" }, "inboxId": { "title": "ID de bandeja de entrada de canal API" }, "relayGroups": { "title": "Reenviar chats de grupo" }, "relayMedia": { "title": "Reenviar multimedia entrante a Chatwoot" }, "relayOwnMessages": { "title": "Reenviar tus propios mensajes enviados" }, "backfillLimit": { "title": "Relleno de historial (mensajes por chat)" }, "backfillAllOnce": { "title": "Importar todos los chats en masa una vez al configurar" } } }, + "fr": { "name": "Adaptateur Chatwoot", "description": "Synchronisation bidirectionnelle entre une session WhatsApp et une boîte de réception Chatwoot : relaie les messages WhatsApp (1:1 et groupes, avec médias) vers Chatwoot en tant que boîte de réception de canal API, renvoie les réponses des agents vers WhatsApp, et transfère un chat à un agent humain — en faisant taire les autres bots OpenWA — lorsqu'un agent le prend en charge dans Chatwoot. Premier consommateur du SDK d'intégration OpenWA v1 ; s'exécute en sandbox dans le worker de plugins.", + "config": { "baseUrl": { "title": "URL de base de Chatwoot" }, "apiToken": { "title": "Jeton d'accès API" }, "accountId": { "title": "ID de compte" }, "inboxId": { "title": "ID de boîte de réception de canal API" }, "relayGroups": { "title": "Relayer les chats de groupe" }, "relayMedia": { "title": "Relayer les médias entrants vers Chatwoot" }, "relayOwnMessages": { "title": "Relayer vos propres messages envoyés" }, "backfillLimit": { "title": "Remplissage d'historique (messages par chat)" }, "backfillAllOnce": { "title": "Importer en masse tous les chats une fois à la configuration" } } }, + "it": { "name": "Adattatore Chatwoot", "description": "Sincronizzazione bidirezionale tra una sessione WhatsApp e una casella di posta Chatwoot: inoltra i messaggi WhatsApp (1:1 e gruppi, con media) a Chatwoot come casella di canale API, restituisce le risposte degli agenti a WhatsApp e passa la chat a un agente umano — silenziando gli altri bot OpenWA — quando un agente la prende in carico in Chatwoot. Primo consumatore dell'OpenWA Integration SDK v1; eseguito in sandbox nel worker dei plugin.", + "config": { "baseUrl": { "title": "URL base di Chatwoot" }, "apiToken": { "title": "Token di accesso API" }, "accountId": { "title": "ID account" }, "inboxId": { "title": "ID casella di canale API" }, "relayGroups": { "title": "Inoltra le chat di gruppo" }, "relayMedia": { "title": "Inoltra i media in arrivo a Chatwoot" }, "relayOwnMessages": { "title": "Inoltra i tuoi messaggi in uscita" }, "backfillLimit": { "title": "Recupero cronologia (messaggi per chat)" }, "backfillAllOnce": { "title": "Importa in blocco tutte le chat una volta alla configurazione" } } }, + "ar": { "name": "موصّل Chatwoot", "description": "مزامنة ثنائية الاتجاه بين جلسة WhatsApp وصندوق وارد Chatwoot: ينقل رسائل WhatsApp (الفردية والجماعية، مع الوسائط) إلى Chatwoot كصندوق وارد بقناة API، ويعيد ردود الوكلاء إلى WhatsApp، ويحوّل المحادثة إلى وكيل بشري — مع إسكات بوتات OpenWA الأخرى — عندما يتولاها وكيل في Chatwoot. أول مستهلك لـ OpenWA Integration SDK v1؛ يعمل في بيئة معزولة داخل عامل الإضافات.", + "config": { "baseUrl": { "title": "الرابط الأساسي لـ Chatwoot" }, "apiToken": { "title": "رمز الوصول إلى API" }, "accountId": { "title": "معرّف الحساب" }, "inboxId": { "title": "معرّف صندوق الوارد بقناة API" }, "relayGroups": { "title": "نقل محادثات المجموعات" }, "relayMedia": { "title": "نقل الوسائط الواردة إلى Chatwoot" }, "relayOwnMessages": { "title": "نقل رسائلك الصادرة الخاصة" }, "backfillLimit": { "title": "استكمال السجل (رسائل لكل محادثة)" }, "backfillAllOnce": { "title": "استيراد جميع المحادثات دفعة واحدة عند الإعداد" } } }, + "he": { "name": "מתאם Chatwoot", "description": "סנכרון דו-כיווני בין סשן WhatsApp לתיבת דואר נכנס של Chatwoot: מעביר הודעות WhatsApp (אישיות וקבוצתיות, עם מדיה) ל-Chatwoot כתיבת דואר בערוץ API, מחזיר תשובות נציגים ל-WhatsApp, ומעביר צ'אט לנציג אנושי — תוך השתקת הבוטים האחרים של OpenWA — כאשר נציג לוקח אותו ב-Chatwoot. הצרכן הראשון של OpenWA Integration SDK v1; רץ בארגז חול בתוך עובד התוספים.", + "config": { "baseUrl": { "title": "כתובת הבסיס של Chatwoot" }, "apiToken": { "title": "אסימון גישה ל-API" }, "accountId": { "title": "מזהה חשבון" }, "inboxId": { "title": "מזהה תיבת דואר בערוץ API" }, "relayGroups": { "title": "העברת צ'אטים קבוצתיים" }, "relayMedia": { "title": "העברת מדיה נכנסת ל-Chatwoot" }, "relayOwnMessages": { "title": "העברת ההודעות היוצאות שלך" }, "backfillLimit": { "title": "מילוי היסטוריה (הודעות לכל צ'אט)" }, "backfillAllOnce": { "title": "ייבוא המוני של כל הצ'אטים פעם אחת בהתקנה" } } }, + "te": { "name": "Chatwoot అడాప్టర్", "description": "WhatsApp సెషన్ మరియు Chatwoot ఇన్‌బాక్స్ మధ్య రెండు మార్గాల సింక్: WhatsApp సందేశాలను (1:1 మరియు గ్రూపులు, మీడియాతో సహా) API-ఛానెల్ ఇన్‌బాక్స్‌గా Chatwoot లోకి రిలే చేస్తుంది, ఏజెంట్ ప్రత్యుత్తరాలను తిరిగి WhatsApp కు పంపుతుంది, మరియు ఏజెంట్ Chatwoot లో చాట్‌ను స్వీకరించినప్పుడు దానిని మానవ ఏజెంట్‌కు అప్పగిస్తుంది — ఇతర OpenWA బాట్‌లను మౌనంగా ఉంచుతూ. OpenWA Integration SDK v1 యొక్క మొదటి వినియోగదారు; ప్లగిన్ వర్కర్‌లో శ్యాండ్‌బాక్స్‌లో నడుస్తుంది.", + "config": { "baseUrl": { "title": "Chatwoot బేస్ URL" }, "apiToken": { "title": "API యాక్సెస్ టోకెన్" }, "accountId": { "title": "అకౌంట్ ID" }, "inboxId": { "title": "API-ఛానెల్ ఇన్‌బాక్స్ ID" }, "relayGroups": { "title": "గ్రూప్ చాట్‌లను రిలే చేయి" }, "relayMedia": { "title": "ఇన్‌బౌండ్ మీడియాను Chatwoot కు రిలే చేయి" }, "relayOwnMessages": { "title": "మీ స్వంత అవుట్‌బౌండ్ పంపకాలను రిలే చేయి" }, "backfillLimit": { "title": "హిస్టరీ బ్యాక్‌ఫిల్ (చాట్‌కు సందేశాలు)" }, "backfillAllOnce": { "title": "సెటప్ సమయంలో అన్ని చాట్‌లను ఒకేసారి బల్క్-ఇంపోర్ట్ చేయి" } } }, + "zh-CN": { "name": "Chatwoot 适配器", "description": "在 WhatsApp 会话与 Chatwoot 收件箱之间双向同步:将 WhatsApp 消息(一对一和群组,含媒体)作为 API 渠道收件箱转发到 Chatwoot,把客服回复发回 WhatsApp,并在客服在 Chatwoot 接管会话时将其转交给人工客服——同时让其他 OpenWA 机器人静默。OpenWA Integration SDK v1 的首个使用者;在插件工作器中以沙箱方式运行。", + "config": { "baseUrl": { "title": "Chatwoot 基础 URL" }, "apiToken": { "title": "API 访问令牌" }, "accountId": { "title": "账户 ID" }, "inboxId": { "title": "API 渠道收件箱 ID" }, "relayGroups": { "title": "转发群聊" }, "relayMedia": { "title": "将接收的媒体转发到 Chatwoot" }, "relayOwnMessages": { "title": "转发你自己发送的消息" }, "backfillLimit": { "title": "历史回填(每个会话的消息数)" }, "backfillAllOnce": { "title": "设置时一次性批量导入所有会话" } } }, + "zh-HK": { "name": "Chatwoot 適配器", "description": "在 WhatsApp 工作階段與 Chatwoot 收件箱之間雙向同步:將 WhatsApp 訊息(一對一及群組,含媒體)以 API 渠道收件箱形式轉發到 Chatwoot,把客服回覆傳回 WhatsApp,並在客服於 Chatwoot 接管對話時轉交給人工客服——同時令其他 OpenWA 機器人靜音。OpenWA Integration SDK v1 的首個使用者;在插件工作器中以沙箱方式運行。", + "config": { "baseUrl": { "title": "Chatwoot 基礎 URL" }, "apiToken": { "title": "API 存取權杖" }, "accountId": { "title": "帳戶 ID" }, "inboxId": { "title": "API 渠道收件箱 ID" }, "relayGroups": { "title": "轉發群組對話" }, "relayMedia": { "title": "將接收的媒體轉發到 Chatwoot" }, "relayOwnMessages": { "title": "轉發你自己發送的訊息" }, "backfillLimit": { "title": "歷史回填(每個對話的訊息數)" }, "backfillAllOnce": { "title": "設定時一次性批量匯入所有對話" } } } } } diff --git a/chatwoot-adapter/outbound.ts b/chatwoot-adapter/outbound.ts index 0bee9f5..7ba7e0c 100644 --- a/chatwoot-adapter/outbound.ts +++ b/chatwoot-adapter/outbound.ts @@ -61,12 +61,15 @@ async function relay(deps: OutboundDeps, sessionId: string | undefined, evt: Cha } const lockKey = await deps.engine.canonicalChatId(target.sessionId, target.chatId); await deps.lock.run(`${target.sessionId}:${lockKey}`, async () => { + // Same conversation-scoped key relayMessage holds across post+mark: an echo webhook that arrives + // while the adapter's own post is still in flight waits here until the echo marker has landed. + await deps.lock.run(`${target.sessionId}:conv:${conversationId}`, async () => { const id = evt.id !== undefined ? String(evt.id) : undefined; // Dedup, but mark only AFTER a successful send: a transient send failure must retry the reply, not be // silently suppressed as "already seen". // // Scoped by target.sessionId — the WA session that owns this conversation, just resolved from the - // mapping — NOT the delivery's `sessionId`. Both identify the tenant (F-02/F-03), but the delivery + // mapping — NOT the delivery's `sessionId`. Both identify the tenant, but the delivery // scope is `instance.sessionScope ?? undefined` and so is UNDEFINED for an unscoped instance, which // would put its markers in a global namespace keyed by bare Chatwoot message id: two tenants whose // ids collide could then suppress each other's replies. target.sessionId is always defined, and is @@ -106,6 +109,7 @@ async function relay(deps: OutboundDeps, sessionId: string | undefined, evt: Cha // silently open. deps.log('conversation.send returned no message id; own-send echo guard skipped for this reply'); } + }); }); } diff --git a/chatwoot-adapter/relay.ts b/chatwoot-adapter/relay.ts index c808cbc..6b9ef9d 100644 --- a/chatwoot-adapter/relay.ts +++ b/chatwoot-adapter/relay.ts @@ -68,6 +68,11 @@ export async function relayMessage( msg: IncomingMessage, messageType: 'incoming' | 'outgoing', ): Promise { + // Conversation-scoped lock spanning the POST + echo-marker write. outbound.relay dedups the webhook + // echo under the SAME key, so an echo processed while this POST is still in flight waits for the + // marker instead of racing it (the surrounding per-chat locks don't help: inbound/backfill lock the + // raw chatId, outbound the canonical one). Innermost on every path, so no lock-order cycle. + await deps.lock.run(`${sessionId}:conv:${conversationId}`, async () => { const content = prefixSender(msg); const post = { sourceId: msg.id, inReplyToExternalId: msg.quotedMessage?.id, messageType }; const isVoice = msg.type === 'voice'; @@ -100,6 +105,7 @@ export async function relayMessage( // is always defined — never the ingress delivery's `instance.sessionScope ?? undefined`, which is // undefined for an unscoped instance and would key a different marker. if (messageType === 'outgoing') await deps.store.markSeen('cw', String(created.id), sessionId); + }); } // Get-or-create the Chatwoot contact + conversation for a chat and mirror the mapping. Self-contained so From d0ccdebb693cad17ad09f6f8fa92c050339fa44b Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Thu, 23 Jul 2026 16:25:35 +0700 Subject: [PATCH 8/9] docs: record the observed runtime contract and known LID limitations PLUGIN-STANDARD.md gains a runtime contract (observed) section covering the res.body-only fetch response, the ~5 s hook budget, allowConfigHosts fail-fast, and the boot-time INSTALLED reset, plus the manifest gates now enforced by the catalog. PluginNetResponse.text()/json()/arrayBuffer() are marked @deprecated since the sandbox runtime does not provide them. The after-hours, faq-bot, and chat-flow READMEs document that per-chat state keyed by chat id can be orphaned when a contact migrates between @lid and @c.us addressing. --- PLUGIN-STANDARD.md | 38 +++++++++++++++++++++++++++++++++++--- after-hours/README.md | 7 +++++++ chat-flow/README.md | 7 +++++++ faq-bot/README.md | 7 +++++++ types/openwa.d.ts | 3 +++ 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/PLUGIN-STANDARD.md b/PLUGIN-STANDARD.md index 7652043..6369f87 100644 --- a/PLUGIN-STANDARD.md +++ b/PLUGIN-STANDARD.md @@ -70,6 +70,10 @@ OpenWA-plugins/ } ``` +Repo gates (enforced by `scripts/catalog.mjs`, hard failure in CI): every manifest must declare +`sessionScoped` explicitly, and a `status: "stable"` manifest must declare `testedOpenWAVersion`. A +missing `i18n` block (or missing locales) is a warning, not a failure. + ### `configSchema` field vocabulary (v0.7) A JSON-Schema-ish object the host renders into an authenticated form (writes go through @@ -149,6 +153,32 @@ session"). See the per-plugin README convention below. **Package limits** (enforced by OpenWA at install): ≤ 5 MB compressed, ≤ 200 files, ≤ 20 MB uncompressed. **Ship compiled JS** — the loader `require()`s `main`; build with `node package.mjs `. +## Runtime contract (observed) + +These behaviors are **observed from the host, not a written host contract** — they are load-bearing for +correct plugins and were each learned the hard way. Re-verify against OpenWA core when upgrading. + +1. **`ctx.net.fetch` responses have no working `.json()` / `.text()` / `.arrayBuffer()`** — those method + forms exist on `PluginNetResponse` only so older plugins still type-check; calling them at runtime + throws (functions cannot cross the worker structuredClone boundary). Always read `res.body` (a UTF-8 + string, capped at 10 MiB host-side) and `JSON.parse(res.body)`. +2. **Hook handlers are bounded to ~5 s.** Never await slow work (HTTP calls, media processing) inside a + hook: return `{ continue: true }` synchronously and float the promise + (`void handle().catch(log)`). The same applies to ingress handlers (see supabase-otp-hook's + fire-and-forget send). +3. **Hosts declared via config (`net.allowConfigHosts`) must be required, non-empty config** — the net + gate reads the RAW `ctx.config`, so a code-side default host is invisible to the gate and every fetch + silently no-ops. Fail fast in `readConfig` when the host field is empty. +4. **Host boot resets every plugin to INSTALLED** — operators must re-enable plugins after a restart. + `ctx.config` and `ctx.storage` survive, so dedup/state markers in storage are safe. +5. **Cross-host redirects cannot be blocked plugin-side** (`PluginNetRequestInit` exposes no redirect + option) — redirect-based SSRF defense is the host's job; do not treat it as a plugin release gate. + +`minOpenWAVersion` is advisory (never enforced by the host). Still bump it when a plugin *requires* a +newer capability: `canonicalChatId` → 0.8.7, Integration SDK v1 (`sdkVersion: 1`) → 0.8.x, +`getChatHistory` → 0.8.5. Keep `testedOpenWAVersion` honest: it is the newest host the plugin was +actually smoke-tested against. + ## `README.md` — required sections (in order) 1. **Title + one-line tagline**, then a small badge row in this exact order: @@ -208,10 +238,12 @@ for a future OpenWA in-dashboard marketplace. | Script | What it does | | ------ | ------------ | -| `node package.mjs ` | Validate manifest (required fields + `version` == top CHANGELOG heading), bundle to `dist/index.js`, zip to `.zip`, print size + sha256. | +| `node package.mjs ` | Validate manifest (required fields + `version` == top CHANGELOG heading), bundle to `dist/index.js`, zip to `.zip` with the built-in STORE writer (`scripts/zip-store.mjs` — no external `zip` CLI needed), print size + sha256. | | `npm run catalog` | Regenerate `plugins.json`, the root README catalog table, and every plugin README **Details** block. | -| `npm run catalog:check` | Same, in-memory; fail if the committed files are out of date or a version↔changelog drift exists (CI). | -| `npm test` / `npm run typecheck` | Run the test suite (`node --test` + `tsx`) and `tsc --noEmit`. | +| `npm run catalog:check` | Same, in-memory; fail if the committed files are out of date, a version↔changelog drift exists, or a manifest gate fails (CI). | +| `npm test` | Run the full suite (`scripts/run-tests.mjs` auto-discovers every plugin dir by its `manifest.json`, plus `scripts/`) with `node --test` + `tsx`. | +| `npm run test:coverage` | Same, with Node's built-in coverage report. | +| `npm run typecheck` | `tsc --noEmit` over every `*/**/*.ts` (plugin dirs are not hardcoded). | | `node scripts/download-badges.mjs [out-dir]` | Sum per-plugin .zip downloads across all GitHub Releases and write shields.io endpoint JSON files (run by the `download-badges` workflow; also runnable locally to preview the numbers). | ## Release process diff --git a/after-hours/README.md b/after-hours/README.md index 239fc87..43879a4 100644 --- a/after-hours/README.md +++ b/after-hours/README.md @@ -94,6 +94,13 @@ so schedule edits apply live without a re-enable). the next inbound message (config is re-read per event). For example, two sessions can have different business hours and away messages. +### Known limitations + +- **WhatsApp `@lid` migration:** the per-chat cooldown is keyed by the chat id as received. If a contact + migrates between a `@lid` privacy id and a phone-based `@c.us` id, the old entry is orphaned and the + new id is treated as a fresh chat (the cooldown simply resets — harmless). A proper fix needs a + host-side lid↔phone resolver; tracked upstream. + ## Security The plugin declares only `messages:send` and makes no outbound network calls. The away message is the diff --git a/chat-flow/README.md b/chat-flow/README.md index c3e2602..d8a0f6d 100644 --- a/chat-flow/README.md +++ b/chat-flow/README.md @@ -116,6 +116,13 @@ message (config is re-read per event). For example, two sessions can run differe state is keyed per `(session, chat)` in storage, so flows for sessions with different menus never interfere. +### Known limitations + +- **WhatsApp `@lid` migration:** flow state is keyed by the chat id as received. If a contact migrates + between a `@lid` privacy id and a phone-based `@c.us` id mid-flow, the in-progress menu state is + orphaned and the contact restarts from the greeting on the new id. A proper fix needs a host-side + lid↔phone resolver; tracked upstream. + ## Security Flow state lives in `ctx.storage`, keyed per `(session, chat)` and expiring after 15 minutes — no diff --git a/faq-bot/README.md b/faq-bot/README.md index 9d756ae..0a5cfda 100644 --- a/faq-bot/README.md +++ b/faq-bot/README.md @@ -96,6 +96,13 @@ on v0.6.0/v0.6.1 a disable + re-enable is needed after changing rules. the next inbound message (config is re-read per event). For example, two sessions can run different FAQ rule sets. +### Known limitations + +- **WhatsApp `@lid` migration:** the fallback cooldown is keyed by the chat id as received. If a contact + migrates between a `@lid` privacy id and a phone-based `@c.us` id, the old entry is orphaned and the + new id is treated as a fresh chat (the cooldown simply resets — harmless). A proper fix needs a + host-side lid↔phone resolver; tracked upstream. + ## Security `regex` patterns are operator-authored (trusted) and tested against at most the first 1000 characters diff --git a/types/openwa.d.ts b/types/openwa.d.ts index 705cd1a..48b9ecd 100644 --- a/types/openwa.d.ts +++ b/types/openwa.d.ts @@ -103,8 +103,11 @@ export interface PluginNetResponse { // NOTE: these method forms are NOT provided by the sandbox runtime (functions cannot cross the // worker structuredClone boundary). Use `body` above; the methods are retained only so older // plugins still type-check. Calling them at runtime throws. + /** @deprecated Not provided by the sandbox runtime — calling it throws. Use `body` instead. */ text(): Promise; + /** @deprecated Not provided by the sandbox runtime — calling it throws. Use `JSON.parse(body)` instead. */ json(): Promise; + /** @deprecated Not provided by the sandbox runtime — calling it throws. Use `body` instead. */ arrayBuffer(): Promise; } From 1ffba2767a19538fe027ebaf0948b128eacc6c48 Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Thu, 23 Jul 2026 16:25:47 +0700 Subject: [PATCH 9/9] chore: regenerate plugin catalog --- README.md | 6 +- plugins.json | 1275 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 1234 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 6a548cd..680e177 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,12 @@ This repository provides: | ------ | ----------- | ------- | ------ | | [`after-hours`](./after-hours) | Auto-replies with a configurable away/closing message to messages received outside business hours. | 0.1.3 | stable | | [`chat-flow`](./chat-flow) | Interactive, stateful auto-reply: a trigger word starts a greeting + numbered menu, replies traverse a configurable menu tree, and per-chat state expires after 15 minutes. | 1.0.7 | stable | -| [`chatwoot-adapter`](./chatwoot-adapter) | Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker. | 0.5.5 | stable | +| [`chatwoot-adapter`](./chatwoot-adapter) | Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker. | 0.5.6 | stable | | [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.1.7 | stable | | [`group-translate`](./group-translate) | Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled. | 1.0.6 | stable | -| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.2.3 | stable | +| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.3.0 | stable | | [`http-action`](./http-action) | Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat. | 0.1.0 | beta | -| [`supabase-otp-hook`](./supabase-otp-hook) | Deliver Supabase Auth phone OTPs over WhatsApp. | 0.1.0 | beta | +| [`supabase-otp-hook`](./supabase-otp-hook) | Deliver Supabase Auth phone OTPs over WhatsApp. | 0.2.0 | beta | | [`typebot-connector`](./typebot-connector) | Runs a Typebot flow as the brain of a WhatsApp bot: inbound messages drive a Typebot chat session via the live Chat API, and the bot's replies — text, media, and numbered-choice inputs — are sent back to WhatsApp. Auto-starts every chat, handles file-upload steps, and resets when the flow ends or after an idle timeout. Runs sandboxed in the plugin worker; no public URL or webhook required. | 0.1.0 | beta | | [`voice-transcription`](./voice-transcription) | Transcribes inbound WhatsApp voice notes to text via an OpenAI-compatible speech-to-text backend (self-hosted Speaches/faster-whisper or hosted Groq/OpenAI) and delivers a `message.transcription` event to your webhook — so bots and AI can read and reply to audio. Off the message-delivery path; disabled until enabled. | 1.0.2 | beta | diff --git a/plugins.json b/plugins.json index f4a51cb..c4a121b 100644 --- a/plugins.json +++ b/plugins.json @@ -369,7 +369,7 @@ { "id": "chatwoot-adapter", "name": "Chatwoot Adapter", - "version": "0.5.5", + "version": "0.5.6", "type": "extension", "status": "stable", "description": "Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker.", @@ -387,11 +387,277 @@ ], "minOpenWAVersion": "0.8.7", "testedOpenWAVersion": "0.10.5", - "releasedAt": "2026-07-22", + "releasedAt": "2026-07-23", "repoPath": "chatwoot-adapter", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/chatwoot-adapter", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chatwoot-adapter-v0.5.5/chatwoot-adapter.zip" + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/chatwoot-adapter-v0.5.6/chatwoot-adapter.zip", + "i18n": { + "es": { + "name": "Adaptador de Chatwoot", + "description": "Sincronización bidireccional entre una sesión de WhatsApp y una bandeja de entrada de Chatwoot: reenvía los mensajes de WhatsApp (1:1 y grupos, con multimedia) a Chatwoot como una bandeja de canal API, devuelve las respuestas de los agentes a WhatsApp y transfiere el chat a un agente humano —silenciando a los demás bots de OpenWA— cuando un agente lo toma en Chatwoot. Primer consumidor del OpenWA Integration SDK v1; se ejecuta en sandbox dentro del worker de plugins.", + "config": { + "baseUrl": { + "title": "URL base de Chatwoot" + }, + "apiToken": { + "title": "Token de acceso a la API" + }, + "accountId": { + "title": "ID de cuenta" + }, + "inboxId": { + "title": "ID de bandeja de entrada de canal API" + }, + "relayGroups": { + "title": "Reenviar chats de grupo" + }, + "relayMedia": { + "title": "Reenviar multimedia entrante a Chatwoot" + }, + "relayOwnMessages": { + "title": "Reenviar tus propios mensajes enviados" + }, + "backfillLimit": { + "title": "Relleno de historial (mensajes por chat)" + }, + "backfillAllOnce": { + "title": "Importar todos los chats en masa una vez al configurar" + } + } + }, + "fr": { + "name": "Adaptateur Chatwoot", + "description": "Synchronisation bidirectionnelle entre une session WhatsApp et une boîte de réception Chatwoot : relaie les messages WhatsApp (1:1 et groupes, avec médias) vers Chatwoot en tant que boîte de réception de canal API, renvoie les réponses des agents vers WhatsApp, et transfère un chat à un agent humain — en faisant taire les autres bots OpenWA — lorsqu'un agent le prend en charge dans Chatwoot. Premier consommateur du SDK d'intégration OpenWA v1 ; s'exécute en sandbox dans le worker de plugins.", + "config": { + "baseUrl": { + "title": "URL de base de Chatwoot" + }, + "apiToken": { + "title": "Jeton d'accès API" + }, + "accountId": { + "title": "ID de compte" + }, + "inboxId": { + "title": "ID de boîte de réception de canal API" + }, + "relayGroups": { + "title": "Relayer les chats de groupe" + }, + "relayMedia": { + "title": "Relayer les médias entrants vers Chatwoot" + }, + "relayOwnMessages": { + "title": "Relayer vos propres messages envoyés" + }, + "backfillLimit": { + "title": "Remplissage d'historique (messages par chat)" + }, + "backfillAllOnce": { + "title": "Importer en masse tous les chats une fois à la configuration" + } + } + }, + "it": { + "name": "Adattatore Chatwoot", + "description": "Sincronizzazione bidirezionale tra una sessione WhatsApp e una casella di posta Chatwoot: inoltra i messaggi WhatsApp (1:1 e gruppi, con media) a Chatwoot come casella di canale API, restituisce le risposte degli agenti a WhatsApp e passa la chat a un agente umano — silenziando gli altri bot OpenWA — quando un agente la prende in carico in Chatwoot. Primo consumatore dell'OpenWA Integration SDK v1; eseguito in sandbox nel worker dei plugin.", + "config": { + "baseUrl": { + "title": "URL base di Chatwoot" + }, + "apiToken": { + "title": "Token di accesso API" + }, + "accountId": { + "title": "ID account" + }, + "inboxId": { + "title": "ID casella di canale API" + }, + "relayGroups": { + "title": "Inoltra le chat di gruppo" + }, + "relayMedia": { + "title": "Inoltra i media in arrivo a Chatwoot" + }, + "relayOwnMessages": { + "title": "Inoltra i tuoi messaggi in uscita" + }, + "backfillLimit": { + "title": "Recupero cronologia (messaggi per chat)" + }, + "backfillAllOnce": { + "title": "Importa in blocco tutte le chat una volta alla configurazione" + } + } + }, + "ar": { + "name": "موصّل Chatwoot", + "description": "مزامنة ثنائية الاتجاه بين جلسة WhatsApp وصندوق وارد Chatwoot: ينقل رسائل WhatsApp (الفردية والجماعية، مع الوسائط) إلى Chatwoot كصندوق وارد بقناة API، ويعيد ردود الوكلاء إلى WhatsApp، ويحوّل المحادثة إلى وكيل بشري — مع إسكات بوتات OpenWA الأخرى — عندما يتولاها وكيل في Chatwoot. أول مستهلك لـ OpenWA Integration SDK v1؛ يعمل في بيئة معزولة داخل عامل الإضافات.", + "config": { + "baseUrl": { + "title": "الرابط الأساسي لـ Chatwoot" + }, + "apiToken": { + "title": "رمز الوصول إلى API" + }, + "accountId": { + "title": "معرّف الحساب" + }, + "inboxId": { + "title": "معرّف صندوق الوارد بقناة API" + }, + "relayGroups": { + "title": "نقل محادثات المجموعات" + }, + "relayMedia": { + "title": "نقل الوسائط الواردة إلى Chatwoot" + }, + "relayOwnMessages": { + "title": "نقل رسائلك الصادرة الخاصة" + }, + "backfillLimit": { + "title": "استكمال السجل (رسائل لكل محادثة)" + }, + "backfillAllOnce": { + "title": "استيراد جميع المحادثات دفعة واحدة عند الإعداد" + } + } + }, + "he": { + "name": "מתאם Chatwoot", + "description": "סנכרון דו-כיווני בין סשן WhatsApp לתיבת דואר נכנס של Chatwoot: מעביר הודעות WhatsApp (אישיות וקבוצתיות, עם מדיה) ל-Chatwoot כתיבת דואר בערוץ API, מחזיר תשובות נציגים ל-WhatsApp, ומעביר צ'אט לנציג אנושי — תוך השתקת הבוטים האחרים של OpenWA — כאשר נציג לוקח אותו ב-Chatwoot. הצרכן הראשון של OpenWA Integration SDK v1; רץ בארגז חול בתוך עובד התוספים.", + "config": { + "baseUrl": { + "title": "כתובת הבסיס של Chatwoot" + }, + "apiToken": { + "title": "אסימון גישה ל-API" + }, + "accountId": { + "title": "מזהה חשבון" + }, + "inboxId": { + "title": "מזהה תיבת דואר בערוץ API" + }, + "relayGroups": { + "title": "העברת צ'אטים קבוצתיים" + }, + "relayMedia": { + "title": "העברת מדיה נכנסת ל-Chatwoot" + }, + "relayOwnMessages": { + "title": "העברת ההודעות היוצאות שלך" + }, + "backfillLimit": { + "title": "מילוי היסטוריה (הודעות לכל צ'אט)" + }, + "backfillAllOnce": { + "title": "ייבוא המוני של כל הצ'אטים פעם אחת בהתקנה" + } + } + }, + "te": { + "name": "Chatwoot అడాప్టర్", + "description": "WhatsApp సెషన్ మరియు Chatwoot ఇన్‌బాక్స్ మధ్య రెండు మార్గాల సింక్: WhatsApp సందేశాలను (1:1 మరియు గ్రూపులు, మీడియాతో సహా) API-ఛానెల్ ఇన్‌బాక్స్‌గా Chatwoot లోకి రిలే చేస్తుంది, ఏజెంట్ ప్రత్యుత్తరాలను తిరిగి WhatsApp కు పంపుతుంది, మరియు ఏజెంట్ Chatwoot లో చాట్‌ను స్వీకరించినప్పుడు దానిని మానవ ఏజెంట్‌కు అప్పగిస్తుంది — ఇతర OpenWA బాట్‌లను మౌనంగా ఉంచుతూ. OpenWA Integration SDK v1 యొక్క మొదటి వినియోగదారు; ప్లగిన్ వర్కర్‌లో శ్యాండ్‌బాక్స్‌లో నడుస్తుంది.", + "config": { + "baseUrl": { + "title": "Chatwoot బేస్ URL" + }, + "apiToken": { + "title": "API యాక్సెస్ టోకెన్" + }, + "accountId": { + "title": "అకౌంట్ ID" + }, + "inboxId": { + "title": "API-ఛానెల్ ఇన్‌బాక్స్ ID" + }, + "relayGroups": { + "title": "గ్రూప్ చాట్‌లను రిలే చేయి" + }, + "relayMedia": { + "title": "ఇన్‌బౌండ్ మీడియాను Chatwoot కు రిలే చేయి" + }, + "relayOwnMessages": { + "title": "మీ స్వంత అవుట్‌బౌండ్ పంపకాలను రిలే చేయి" + }, + "backfillLimit": { + "title": "హిస్టరీ బ్యాక్‌ఫిల్ (చాట్‌కు సందేశాలు)" + }, + "backfillAllOnce": { + "title": "సెటప్ సమయంలో అన్ని చాట్‌లను ఒకేసారి బల్క్-ఇంపోర్ట్ చేయి" + } + } + }, + "zh-CN": { + "name": "Chatwoot 适配器", + "description": "在 WhatsApp 会话与 Chatwoot 收件箱之间双向同步:将 WhatsApp 消息(一对一和群组,含媒体)作为 API 渠道收件箱转发到 Chatwoot,把客服回复发回 WhatsApp,并在客服在 Chatwoot 接管会话时将其转交给人工客服——同时让其他 OpenWA 机器人静默。OpenWA Integration SDK v1 的首个使用者;在插件工作器中以沙箱方式运行。", + "config": { + "baseUrl": { + "title": "Chatwoot 基础 URL" + }, + "apiToken": { + "title": "API 访问令牌" + }, + "accountId": { + "title": "账户 ID" + }, + "inboxId": { + "title": "API 渠道收件箱 ID" + }, + "relayGroups": { + "title": "转发群聊" + }, + "relayMedia": { + "title": "将接收的媒体转发到 Chatwoot" + }, + "relayOwnMessages": { + "title": "转发你自己发送的消息" + }, + "backfillLimit": { + "title": "历史回填(每个会话的消息数)" + }, + "backfillAllOnce": { + "title": "设置时一次性批量导入所有会话" + } + } + }, + "zh-HK": { + "name": "Chatwoot 適配器", + "description": "在 WhatsApp 工作階段與 Chatwoot 收件箱之間雙向同步:將 WhatsApp 訊息(一對一及群組,含媒體)以 API 渠道收件箱形式轉發到 Chatwoot,把客服回覆傳回 WhatsApp,並在客服於 Chatwoot 接管對話時轉交給人工客服——同時令其他 OpenWA 機器人靜音。OpenWA Integration SDK v1 的首個使用者;在插件工作器中以沙箱方式運行。", + "config": { + "baseUrl": { + "title": "Chatwoot 基礎 URL" + }, + "apiToken": { + "title": "API 存取權杖" + }, + "accountId": { + "title": "帳戶 ID" + }, + "inboxId": { + "title": "API 渠道收件箱 ID" + }, + "relayGroups": { + "title": "轉發群組對話" + }, + "relayMedia": { + "title": "將接收的媒體轉發到 Chatwoot" + }, + "relayOwnMessages": { + "title": "轉發你自己發送的訊息" + }, + "backfillLimit": { + "title": "歷史回填(每個對話的訊息數)" + }, + "backfillAllOnce": { + "title": "設定時一次性批量匯入所有對話" + } + } + } + } }, { "id": "faq-bot", @@ -810,7 +1076,7 @@ { "id": "gsheets-logger", "name": "Google Sheets Logger", - "version": "0.2.3", + "version": "0.3.0", "type": "extension", "status": "stable", "description": "Logs WhatsApp message events to a Google Sheet via a service account.", @@ -824,13 +1090,13 @@ "whatsapp", "openwa" ], - "minOpenWAVersion": "0.6.1", + "minOpenWAVersion": "0.7.0", "testedOpenWAVersion": "0.8.1", - "releasedAt": "2026-07-02", + "releasedAt": "2026-07-23", "repoPath": "gsheets-logger", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/gsheets-logger", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/gsheets-logger-v0.2.3/gsheets-logger.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/gsheets-logger-v0.3.0/gsheets-logger.zip", "i18n": { "es": { "name": "Registrador en Google Sheets", @@ -1025,12 +1291,254 @@ "repoPath": "http-action", "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/http-action", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/http-action-v0.1.0/http-action.zip" + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/http-action-v0.1.0/http-action.zip", + "i18n": { + "es": { + "name": "Bot de acciones HTTP", + "description": "Ejecuta solicitudes seguras a API REST desde comandos de WhatsApp y muestra las respuestas JSON en el chat.", + "config": { + "baseUrl": { + "title": "URL base" + }, + "authType": { + "title": "Tipo de autenticación" + }, + "authToken": { + "title": "Token / clave de API" + }, + "apiKeyHeader": { + "title": "Encabezado de clave de API" + }, + "respondInGroups": { + "title": "Responder en grupos" + }, + "timeoutMs": { + "title": "Tiempo de espera de la solicitud (ms)" + }, + "cooldownSeconds": { + "title": "Enfriamiento (segundos)" + }, + "actions": { + "title": "Acciones (array JSON)" + } + } + }, + "fr": { + "name": "Bot d'actions HTTP", + "description": "Déclenche des requêtes API REST sécurisées depuis des commandes WhatsApp et affiche les réponses JSON dans le chat.", + "config": { + "baseUrl": { + "title": "URL de base" + }, + "authType": { + "title": "Type d'authentification" + }, + "authToken": { + "title": "Jeton / clé API" + }, + "apiKeyHeader": { + "title": "En-tête de clé API" + }, + "respondInGroups": { + "title": "Répondre dans les groupes" + }, + "timeoutMs": { + "title": "Délai d'attente de la requête (ms)" + }, + "cooldownSeconds": { + "title": "Temps de recharge (secondes)" + }, + "actions": { + "title": "Actions (tableau JSON)" + } + } + }, + "it": { + "name": "Bot di azioni HTTP", + "description": "Esegue richieste sicure a API REST dai comandi WhatsApp e mostra le risposte JSON nella chat.", + "config": { + "baseUrl": { + "title": "URL di base" + }, + "authType": { + "title": "Tipo di autenticazione" + }, + "authToken": { + "title": "Token / chiave API" + }, + "apiKeyHeader": { + "title": "Header della chiave API" + }, + "respondInGroups": { + "title": "Rispondi nei gruppi" + }, + "timeoutMs": { + "title": "Timeout della richiesta (ms)" + }, + "cooldownSeconds": { + "title": "Tempo di attesa (secondi)" + }, + "actions": { + "title": "Azioni (array JSON)" + } + } + }, + "ar": { + "name": "بوت إجراءات HTTP", + "description": "يشغّل طلبات REST API آمنة من أوامر WhatsApp ويعرض استجابات JSON في المحادثة.", + "config": { + "baseUrl": { + "title": "الرابط الأساسي" + }, + "authType": { + "title": "نوع المصادقة" + }, + "authToken": { + "title": "الرمز / مفتاح API" + }, + "apiKeyHeader": { + "title": "ترويسة مفتاح API" + }, + "respondInGroups": { + "title": "الرد في المجموعات" + }, + "timeoutMs": { + "title": "مهلة الطلب (ميلي ثانية)" + }, + "cooldownSeconds": { + "title": "فترة التهدئة (بالثواني)" + }, + "actions": { + "title": "الإجراءات (مصفوفة JSON)" + } + } + }, + "he": { + "name": "בוט פעולות HTTP", + "description": "מפעיל בקשות REST API בטוחות מפקודות WhatsApp ומציג תגובות JSON חזרה בצ'אט.", + "config": { + "baseUrl": { + "title": "כתובת בסיס" + }, + "authType": { + "title": "סוג אימות" + }, + "authToken": { + "title": "אסימון / מפתח API" + }, + "apiKeyHeader": { + "title": "כותרת מפתח API" + }, + "respondInGroups": { + "title": "השב בקבוצות" + }, + "timeoutMs": { + "title": "פסק זמן לבקשה (ms)" + }, + "cooldownSeconds": { + "title": "צינון (שניות)" + }, + "actions": { + "title": "פעולות (מערך JSON)" + } + } + }, + "te": { + "name": "HTTP యాక్షన్ బాట్", + "description": "WhatsApp కమాండ్‌ల నుండి సురక్షిత REST API అభ్యర్థనలను ట్రిగర్ చేసి, JSON ప్రతిస్పందనలను చాట్‌లో చూపిస్తుంది.", + "config": { + "baseUrl": { + "title": "బేస్ URL" + }, + "authType": { + "title": "ప్రామాణీకరణ రకం" + }, + "authToken": { + "title": "టోకెన్ / API కీ" + }, + "apiKeyHeader": { + "title": "API కీ హెడర్" + }, + "respondInGroups": { + "title": "గ్రూపులలో స్పందించు" + }, + "timeoutMs": { + "title": "అభ్యర్థన గడువు (ms)" + }, + "cooldownSeconds": { + "title": "కూల్‌డౌన్ (సెకన్లు)" + }, + "actions": { + "title": "చర్యలు (JSON శ్రేణి)" + } + } + }, + "zh-CN": { + "name": "HTTP 动作机器人", + "description": "从 WhatsApp 命令触发安全的 REST API 请求,并将 JSON 响应展示回聊天中。", + "config": { + "baseUrl": { + "title": "基础 URL" + }, + "authType": { + "title": "认证类型" + }, + "authToken": { + "title": "令牌 / API 密钥" + }, + "apiKeyHeader": { + "title": "API 密钥请求头" + }, + "respondInGroups": { + "title": "在群组中回复" + }, + "timeoutMs": { + "title": "请求超时(毫秒)" + }, + "cooldownSeconds": { + "title": "冷却时间(秒)" + }, + "actions": { + "title": "动作(JSON 数组)" + } + } + }, + "zh-HK": { + "name": "HTTP 動作機器人", + "description": "從 WhatsApp 指令觸發安全的 REST API 請求,並將 JSON 回應顯示回對話中。", + "config": { + "baseUrl": { + "title": "基礎 URL" + }, + "authType": { + "title": "認證類型" + }, + "authToken": { + "title": "權杖 / API 金鑰" + }, + "apiKeyHeader": { + "title": "API 金鑰標頭" + }, + "respondInGroups": { + "title": "在群組中回覆" + }, + "timeoutMs": { + "title": "請求逾時(毫秒)" + }, + "cooldownSeconds": { + "title": "冷卻時間(秒)" + }, + "actions": { + "title": "動作(JSON 陣列)" + } + } + } + } }, { "id": "supabase-otp-hook", "name": "Supabase Auth OTP", - "version": "0.1.0", + "version": "0.2.0", "type": "extension", "status": "beta", "description": "Deliver Supabase Auth phone OTPs over WhatsApp.", @@ -1048,44 +1556,408 @@ ], "minOpenWAVersion": "0.8.16", "testedOpenWAVersion": "0.8.16", - "releasedAt": "2026-07-07", + "releasedAt": "2026-07-23", "repoPath": "supabase-otp-hook", "repoUrl": "https://github.com/maplerichie/OpenWA-plugins", "homepage": "https://github.com/maplerichie/OpenWA-plugins/tree/main/supabase-otp-hook", - "download": "https://github.com/maplerichie/OpenWA-plugins/releases/download/supabase-otp-hook-v0.1.0/supabase-otp-hook.zip" - }, - { - "id": "typebot-connector", - "name": "Typebot Connector", - "version": "0.1.0", - "type": "extension", - "status": "beta", - "description": "Runs a Typebot flow as the brain of a WhatsApp bot: inbound messages drive a Typebot chat session via the live Chat API, and the bot's replies — text, media, and numbered-choice inputs — are sent back to WhatsApp. Auto-starts every chat, handles file-upload steps, and resets when the flow ends or after an idle timeout. Runs sandboxed in the plugin worker; no public URL or webhook required.", - "author": "Yudhi Armyndharis ", - "license": "MIT", - "keywords": [ - "typebot", - "chatbot", - "flow", - "bot", - "no-code", - "two-way", - "whatsapp", - "openwa" - ], - "minOpenWAVersion": "0.8.2", - "testedOpenWAVersion": "0.8.2", - "releasedAt": "2026-07-03", - "repoPath": "typebot-connector", - "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", - "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/typebot-connector", - "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/typebot-connector-v0.1.0/typebot-connector.zip" - }, - { - "id": "voice-transcription", - "name": "Voice Note Transcription", - "version": "1.0.2", - "type": "extension", + "download": "https://github.com/maplerichie/OpenWA-plugins/releases/download/supabase-otp-hook-v0.2.0/supabase-otp-hook.zip", + "i18n": { + "es": { + "name": "OTP de Supabase Auth", + "description": "Entrega los OTP telefónicos de Supabase Auth a través de WhatsApp.", + "config": { + "appName": { + "title": "Nombre de la app" + }, + "messageTemplate": { + "title": "Plantilla de mensaje" + }, + "fallbackSessionId": { + "title": "Sesión de WhatsApp de respaldo" + }, + "debug": { + "title": "Modo de depuración" + } + } + }, + "fr": { + "name": "OTP Supabase Auth", + "description": "Envoie les OTP téléphoniques de Supabase Auth via WhatsApp.", + "config": { + "appName": { + "title": "Nom de l'application" + }, + "messageTemplate": { + "title": "Modèle de message" + }, + "fallbackSessionId": { + "title": "Session WhatsApp de secours" + }, + "debug": { + "title": "Mode débogage" + } + } + }, + "it": { + "name": "OTP di Supabase Auth", + "description": "Invia gli OTP telefonici di Supabase Auth tramite WhatsApp.", + "config": { + "appName": { + "title": "Nome dell'app" + }, + "messageTemplate": { + "title": "Modello di messaggio" + }, + "fallbackSessionId": { + "title": "Sessione WhatsApp di riserva" + }, + "debug": { + "title": "Modalità debug" + } + } + }, + "ar": { + "name": "OTP لـ Supabase Auth", + "description": "يُسلّم رموز OTP الهاتفية من Supabase Auth عبر WhatsApp.", + "config": { + "appName": { + "title": "اسم التطبيق" + }, + "messageTemplate": { + "title": "قالب الرسالة" + }, + "fallbackSessionId": { + "title": "جلسة WhatsApp الاحتياطية" + }, + "debug": { + "title": "وضع التصحيح" + } + } + }, + "he": { + "name": "OTP של Supabase Auth", + "description": "מספק קודי OTP טלפוניים של Supabase Auth דרך WhatsApp.", + "config": { + "appName": { + "title": "שם האפליקציה" + }, + "messageTemplate": { + "title": "תבנית הודעה" + }, + "fallbackSessionId": { + "title": "סשן WhatsApp גיבוי" + }, + "debug": { + "title": "מצב ניפוי שגיאות" + } + } + }, + "te": { + "name": "Supabase Auth OTP", + "description": "Supabase Auth ఫోన్ OTPలను WhatsApp ద్వారా డెలివర్ చేస్తుంది.", + "config": { + "appName": { + "title": "యాప్ పేరు" + }, + "messageTemplate": { + "title": "సందేశ టెంప్లేట్" + }, + "fallbackSessionId": { + "title": "ఫాల్‌బ్యాక్ WhatsApp సెషన్" + }, + "debug": { + "title": "డీబగ్ మోడ్" + } + } + }, + "zh-CN": { + "name": "Supabase Auth OTP", + "description": "通过 WhatsApp 发送 Supabase Auth 手机 OTP 验证码。", + "config": { + "appName": { + "title": "应用名称" + }, + "messageTemplate": { + "title": "消息模板" + }, + "fallbackSessionId": { + "title": "备用 WhatsApp 会话" + }, + "debug": { + "title": "调试模式" + } + } + }, + "zh-HK": { + "name": "Supabase Auth OTP", + "description": "透過 WhatsApp 發送 Supabase Auth 手機 OTP 驗證碼。", + "config": { + "appName": { + "title": "應用程式名稱" + }, + "messageTemplate": { + "title": "訊息範本" + }, + "fallbackSessionId": { + "title": "備用 WhatsApp 工作階段" + }, + "debug": { + "title": "除錯模式" + } + } + } + } + }, + { + "id": "typebot-connector", + "name": "Typebot Connector", + "version": "0.1.0", + "type": "extension", + "status": "beta", + "description": "Runs a Typebot flow as the brain of a WhatsApp bot: inbound messages drive a Typebot chat session via the live Chat API, and the bot's replies — text, media, and numbered-choice inputs — are sent back to WhatsApp. Auto-starts every chat, handles file-upload steps, and resets when the flow ends or after an idle timeout. Runs sandboxed in the plugin worker; no public URL or webhook required.", + "author": "Yudhi Armyndharis ", + "license": "MIT", + "keywords": [ + "typebot", + "chatbot", + "flow", + "bot", + "no-code", + "two-way", + "whatsapp", + "openwa" + ], + "minOpenWAVersion": "0.8.2", + "testedOpenWAVersion": "0.8.2", + "releasedAt": "2026-07-03", + "repoPath": "typebot-connector", + "repoUrl": "https://github.com/rmyndharis/OpenWA-plugins", + "homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/typebot-connector", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/typebot-connector-v0.1.0/typebot-connector.zip", + "i18n": { + "es": { + "name": "Conector de Typebot", + "description": "Ejecuta un flujo de Typebot como el cerebro de un bot de WhatsApp: los mensajes entrantes alimentan una sesión de chat de Typebot a través de la Chat API en vivo, y las respuestas del bot —texto, multimedia e inputs de opción numerada— se devuelven a WhatsApp. Inicia automáticamente cada chat, gestiona los pasos de subida de archivos y se reinicia cuando el flujo termina o tras un tiempo de inactividad. Se ejecuta en sandbox dentro del worker de plugins; no requiere URL pública ni webhook.", + "config": { + "apiHost": { + "title": "URL del host de Typebot" + }, + "publicId": { + "title": "ID público de Typebot" + }, + "apiToken": { + "title": "Token de API" + }, + "respondInGroups": { + "title": "Responder en chats de grupo" + }, + "sessionTimeoutMinutes": { + "title": "Reinicio por inactividad (minutos)" + }, + "passContactVariables": { + "title": "Pasar variables de contacto" + }, + "mediaHost": { + "title": "Host de medios/subida" + } + } + }, + "fr": { + "name": "Connecteur Typebot", + "description": "Exécute un flux Typebot comme cerveau d'un bot WhatsApp : les messages entrants alimentent une session de chat Typebot via la Chat API en direct, et les réponses du bot — texte, médias et choix numérotés — sont renvoyées vers WhatsApp. Démarre automatiquement chaque chat, gère les étapes de téléversement de fichiers et se réinitialise à la fin du flux ou après un délai d'inactivité. S'exécute en sandbox dans le worker de plugins ; aucune URL publique ni webhook requis.", + "config": { + "apiHost": { + "title": "URL de l'hôte Typebot" + }, + "publicId": { + "title": "ID public Typebot" + }, + "apiToken": { + "title": "Jeton API" + }, + "respondInGroups": { + "title": "Répondre dans les chats de groupe" + }, + "sessionTimeoutMinutes": { + "title": "Réinitialisation après inactivité (minutes)" + }, + "passContactVariables": { + "title": "Transmettre les variables de contact" + }, + "mediaHost": { + "title": "Hôte média/téléversement" + } + } + }, + "it": { + "name": "Connettore Typebot", + "description": "Esegue un flusso Typebot come cervello di un bot WhatsApp: i messaggi in arrivo alimentano una sessione di chat Typebot tramite la Chat API live, e le risposte del bot — testo, media e scelte numerate — vengono rinviate a WhatsApp. Avvia automaticamente ogni chat, gestisce i passaggi di caricamento file e si reimposta quando il flusso termina o dopo un timeout di inattività. Eseguito in sandbox nel worker dei plugin; non richiede URL pubblico né webhook.", + "config": { + "apiHost": { + "title": "URL dell'host Typebot" + }, + "publicId": { + "title": "ID pubblico Typebot" + }, + "apiToken": { + "title": "Token API" + }, + "respondInGroups": { + "title": "Rispondi nelle chat di gruppo" + }, + "sessionTimeoutMinutes": { + "title": "Reset per inattività (minuti)" + }, + "passContactVariables": { + "title": "Passa le variabili di contatto" + }, + "mediaHost": { + "title": "Host media/caricamento" + } + } + }, + "ar": { + "name": "موصّل Typebot", + "description": "يشغّل تدفق Typebot كعقل لبوت WhatsApp: تغذّي الرسائل الواردة جلسة دردشة Typebot عبر Chat API المباشرة، وتُعاد ردود البوت — نصوص ووسائط وخيارات مرقّمة — إلى WhatsApp. يبدأ كل محادثة تلقائيًا، ويتعامل مع خطوات رفع الملفات، ويعيد الضبط عند انتهاء التدفق أو بعد مهلة خمول. يعمل في بيئة معزولة داخل عامل الإضافات؛ لا يتطلب رابطًا عامًا أو webhook.", + "config": { + "apiHost": { + "title": "رابط مضيف Typebot" + }, + "publicId": { + "title": "المعرّف العام لـ Typebot" + }, + "apiToken": { + "title": "رمز API" + }, + "respondInGroups": { + "title": "الرد في محادثات المجموعات" + }, + "sessionTimeoutMinutes": { + "title": "إعادة الضبط بعد الخمول (بالدقائق)" + }, + "passContactVariables": { + "title": "تمرير متغيرات جهة الاتصال" + }, + "mediaHost": { + "title": "مضيف الوسائط/الرفع" + } + } + }, + "he": { + "name": "מחבר Typebot", + "description": "מריץ זרימת Typebot כמוח של בוט WhatsApp: הודעות נכנסות מזינות סשן צ'אט של Typebot דרך ה-Chat API החי, ותשובות הבוט — טקסט, מדיה ובחירות ממוספרות — נשלחות חזרה ל-WhatsApp. מתחיל כל צ'אט אוטומטית, מטפל בשלבי העלאת קבצים, ומתאפס כשהזרימה מסתיימת או לאחר פסק זמן חוסר פעילות. רץ בארגז חול בתוך עובד התוספים; לא נדרשת כתובת ציבורית או webhook.", + "config": { + "apiHost": { + "title": "כתובת המארח של Typebot" + }, + "publicId": { + "title": "מזהה ציבורי של Typebot" + }, + "apiToken": { + "title": "אסימון API" + }, + "respondInGroups": { + "title": "השב בצ'אטים קבוצתיים" + }, + "sessionTimeoutMinutes": { + "title": "איפוס חוסר פעילות (דקות)" + }, + "passContactVariables": { + "title": "העבר משתני איש קשר" + }, + "mediaHost": { + "title": "מארח מדיה/העלאה" + } + } + }, + "te": { + "name": "Typebot కనెక్టర్", + "description": "Typebot ఫ్లోను WhatsApp బాట్ యొక్క మెదడుగా నడుపుతుంది: ఇన్‌బౌండ్ సందేశాలు లైవ్ Chat API ద్వారా Typebot చాట్ సెషన్‌ను నడుపుతాయి, మరియు బాట్ ప్రత్యుత్తరాలు — టెక్స్ట్, మీడియా మరియు సంఖ్యా-ఎంపిక ఇన్‌పుట్‌లు — తిరిగి WhatsApp కు పంపబడతాయి. ప్రతి చాట్‌ను స్వయంచాలకంగా ప్రారంభిస్తుంది, ఫైల్-అప్‌లోడ్ దశలను నిర్వహిస్తుంది, మరియు ఫ్లో ముగిసినప్పుడు లేదా ఐడిల్ టైమ్‌అవుట్ తర్వాత రీసెట్ అవుతుంది. ప్లగిన్ వర్కర్‌లో శ్యాండ్‌బాక్స్‌లో నడుస్తుంది; పబ్లిక్ URL లేదా webhook అవసరం లేదు.", + "config": { + "apiHost": { + "title": "Typebot హోస్ట్ URL" + }, + "publicId": { + "title": "Typebot పబ్లిక్ ID" + }, + "apiToken": { + "title": "API టోకెన్" + }, + "respondInGroups": { + "title": "గ్రూప్ చాట్‌లలో స్పందించు" + }, + "sessionTimeoutMinutes": { + "title": "ఐడిల్ రీసెట్ (నిమిషాలు)" + }, + "passContactVariables": { + "title": "కాంటాక్ట్ వేరియబుల్‌లను పంపు" + }, + "mediaHost": { + "title": "మీడియా/అప్‌లోడ్ హోస్ట్" + } + } + }, + "zh-CN": { + "name": "Typebot 连接器", + "description": "将 Typebot 流程作为 WhatsApp 机器人的大脑运行:传入消息通过实时 Chat API 驱动 Typebot 聊天会话,机器人的回复——文本、媒体和编号选项输入——被发送回 WhatsApp。自动开始每个会话,处理文件上传步骤,并在流程结束或空闲超时后重置。在插件工作器中以沙箱方式运行;无需公共 URL 或 webhook。", + "config": { + "apiHost": { + "title": "Typebot 主机 URL" + }, + "publicId": { + "title": "Typebot 公共 ID" + }, + "apiToken": { + "title": "API 令牌" + }, + "respondInGroups": { + "title": "在群聊中回复" + }, + "sessionTimeoutMinutes": { + "title": "空闲重置(分钟)" + }, + "passContactVariables": { + "title": "传递联系人变量" + }, + "mediaHost": { + "title": "媒体/上传主机" + } + } + }, + "zh-HK": { + "name": "Typebot 連接器", + "description": "將 Typebot 流程作為 WhatsApp 機器人的大腦運行:傳入訊息透過實時 Chat API 驅動 Typebot 聊天工作階段,機器人的回覆——文字、媒體及編號選項輸入——會傳回 WhatsApp。自動開始每個對話,處理檔案上傳步驟,並在流程結束或閒置逾時後重設。在插件工作器中以沙箱方式運行;無需公共 URL 或 webhook。", + "config": { + "apiHost": { + "title": "Typebot 主機 URL" + }, + "publicId": { + "title": "Typebot 公開 ID" + }, + "apiToken": { + "title": "API 權杖" + }, + "respondInGroups": { + "title": "在群組對話中回覆" + }, + "sessionTimeoutMinutes": { + "title": "閒置重設(分鐘)" + }, + "passContactVariables": { + "title": "傳遞聯絡人變數" + }, + "mediaHost": { + "title": "媒體/上傳主機" + } + } + } + } + }, + { + "id": "voice-transcription", + "name": "Voice Note Transcription", + "version": "1.0.2", + "type": "extension", "status": "beta", "description": "Transcribes inbound WhatsApp voice notes to text via an OpenAI-compatible speech-to-text backend (self-hosted Speaches/faster-whisper or hosted Groq/OpenAI) and delivers a `message.transcription` event to your webhook — so bots and AI can read and reply to audio. Off the message-delivery path; disabled until enabled.", "author": "Yudhi Armyndharis ", @@ -1111,6 +1983,321 @@ "es": { "name": "Transcripción de Notas de Voz", "description": "Transcribe las notas de voz entrantes de WhatsApp a texto mediante un backend de voz a texto compatible con OpenAI (Speaches/faster-whisper autoalojado o Groq/OpenAI) y entrega un evento message.transcription a tu webhook, para que los bots y la IA puedan leer y responder al audio. Fuera de la ruta de entrega de mensajes; desactivado hasta que se habilite." + }, + "fr": { + "name": "Transcription de notes vocales", + "description": "Transcrit les notes vocales WhatsApp entrantes en texte via un backend de reconnaissance vocale compatible OpenAI (Speaches/faster-whisper auto-hébergé ou Groq/OpenAI hébergé) et envoie un événement `message.transcription` à votre webhook — pour que les bots et l'IA puissent lire et répondre à l'audio. Hors du chemin de livraison des messages ; désactivé jusqu'à activation.", + "config": { + "sttBaseUrl": { + "title": "URL de base STT" + }, + "sttApiKey": { + "title": "Clé API STT" + }, + "model": { + "title": "Modèle" + }, + "language": { + "title": "Indication de langue" + }, + "provider": { + "title": "Étiquette du fournisseur" + }, + "timeoutMs": { + "title": "Délai d'attente STT (ms)" + }, + "enabledMessageTypes": { + "title": "Types de messages à transcrire" + }, + "maxSizeBytes": { + "title": "Taille audio max (octets)" + }, + "maxPerHour": { + "title": "Transcriptions max / heure / session" + }, + "deliveryWebhookUrl": { + "title": "URL du webhook de livraison" + }, + "deliverySecret": { + "title": "Secret de livraison" + }, + "deliveryTimeoutMs": { + "title": "Délai d'attente de livraison (ms)" + }, + "chatDelivery": { + "title": "Livraison dans le chat" + } + } + }, + "it": { + "name": "Trascrizione note vocali", + "description": "Trascrive le note vocali WhatsApp in arrivo in testo tramite un backend speech-to-text compatibile con OpenAI (Speaches/faster-whisper self-hosted o Groq/OpenAI in hosting) e consegna un evento `message.transcription` al tuo webhook — così bot e IA possono leggere e rispondere all'audio. Fuori dal percorso di consegna dei messaggi; disabilitato finché non viene abilitato.", + "config": { + "sttBaseUrl": { + "title": "URL base STT" + }, + "sttApiKey": { + "title": "Chiave API STT" + }, + "model": { + "title": "Modello" + }, + "language": { + "title": "Suggerimento lingua" + }, + "provider": { + "title": "Etichetta del provider" + }, + "timeoutMs": { + "title": "Timeout STT (ms)" + }, + "enabledMessageTypes": { + "title": "Tipi di messaggio da trascrivere" + }, + "maxSizeBytes": { + "title": "Dimensione max audio (byte)" + }, + "maxPerHour": { + "title": "Trascrizioni max / ora / sessione" + }, + "deliveryWebhookUrl": { + "title": "URL del webhook di consegna" + }, + "deliverySecret": { + "title": "Segreto di consegna" + }, + "deliveryTimeoutMs": { + "title": "Timeout di consegna (ms)" + }, + "chatDelivery": { + "title": "Consegna in chat" + } + } + }, + "ar": { + "name": "نسخ الملاحظات الصوتية", + "description": "ينسخ الملاحظات الصوتية الواردة في WhatsApp إلى نص عبر خلفية تحويل كلام إلى نص متوافقة مع OpenAI (Speaches/faster-whisper ذاتية الاستضافة أو Groq/OpenAI المستضافة) ويسلّم حدث `message.transcription` إلى webhook الخاص بك — حتى تتمكن البوتات والذكاء الاصطناعي من قراءة الصوت والرد عليه. خارج مسار تسليم الرسائل؛ معطّل حتى يتم تفعيله.", + "config": { + "sttBaseUrl": { + "title": "الرابط الأساسي لـ STT" + }, + "sttApiKey": { + "title": "مفتاح API لـ STT" + }, + "model": { + "title": "النموذج" + }, + "language": { + "title": "تلميح اللغة" + }, + "provider": { + "title": "تسمية المزوّد" + }, + "timeoutMs": { + "title": "مهلة STT (ميلي ثانية)" + }, + "enabledMessageTypes": { + "title": "أنواع الرسائل المراد نسخها" + }, + "maxSizeBytes": { + "title": "الحد الأقصى لحجم الصوت (بايت)" + }, + "maxPerHour": { + "title": "أقصى عدد نسخ / ساعة / جلسة" + }, + "deliveryWebhookUrl": { + "title": "رابط webhook للتسليم" + }, + "deliverySecret": { + "title": "سرّ التسليم" + }, + "deliveryTimeoutMs": { + "title": "مهلة التسليم (ميلي ثانية)" + }, + "chatDelivery": { + "title": "التسليم داخل المحادثة" + } + } + }, + "he": { + "name": "תמלול הודעות קוליות", + "description": "מתמלל הודעות קוליות נכנסות ב-WhatsApp לטקסט באמצעות שרת דיבור-לטקסט תואם OpenAI (Speaches/faster-whisper באירוח עצמי או Groq/OpenAI באירוח) ומעביר אירוע `message.transcription` ל-webhook שלך — כך שבוטים ו-AI יכולים לקרוא ולהשיב לאודיו. מחוץ לנתיב מסירת ההודעות; מושבת עד שמופעל.", + "config": { + "sttBaseUrl": { + "title": "כתובת בסיס של STT" + }, + "sttApiKey": { + "title": "מפתח API של STT" + }, + "model": { + "title": "מודל" + }, + "language": { + "title": "רמז שפה" + }, + "provider": { + "title": "תווית ספק" + }, + "timeoutMs": { + "title": "פסק זמן STT (ms)" + }, + "enabledMessageTypes": { + "title": "סוגי הודעות לתמלול" + }, + "maxSizeBytes": { + "title": "גודל אודיו מרבי (בייטים)" + }, + "maxPerHour": { + "title": "מקסימום תמלולים / שעה / סשן" + }, + "deliveryWebhookUrl": { + "title": "כתובת webhook למסירה" + }, + "deliverySecret": { + "title": "סוד מסירה" + }, + "deliveryTimeoutMs": { + "title": "פסק זמן מסירה (ms)" + }, + "chatDelivery": { + "title": "מסירה בתוך הצ'אט" + } + } + }, + "te": { + "name": "వాయిస్ నోట్ ట్రాన్స్‌క్రిప్షన్", + "description": "ఇన్‌బౌండ్ WhatsApp వాయిస్ నోట్‌లను OpenAI-అనుకూల స్పీచ్-టు-టెక్స్ట్ బ్యాక్‌ఎండ్ (స్వీయ-హోస్ట్ చేసిన Speaches/faster-whisper లేదా హోస్ట్ చేసిన Groq/OpenAI) ద్వారా టెక్స్ట్‌గా మారుస్తుంది మరియు `message.transcription` ఈవెంట్‌ను మీ webhook కు డెలివర్ చేస్తుంది — తద్వారా బాట్‌లు మరియు AI ఆడియోను చదవగలవు మరియు స్పందించగలవు. సందేశ-డెలివరీ మార్గం నుండి బయట; ప్రారంభించే వరకు నిలిపివేయబడింది.", + "config": { + "sttBaseUrl": { + "title": "STT బేస్ URL" + }, + "sttApiKey": { + "title": "STT API కీ" + }, + "model": { + "title": "మోడల్" + }, + "language": { + "title": "భాషా సూచన" + }, + "provider": { + "title": "ప్రొవైడర్ లేబుల్" + }, + "timeoutMs": { + "title": "STT టైమ్‌అవుట్ (ms)" + }, + "enabledMessageTypes": { + "title": "ట్రాన్స్‌క్రైబ్ చేయాల్సిన సందేశ రకాలు" + }, + "maxSizeBytes": { + "title": "గరిష్ట ఆడియో పరిమాణం (బైట్లు)" + }, + "maxPerHour": { + "title": "గరిష్ట ట్రాన్స్‌క్రిప్షన్‌లు / గంట / సెషన్" + }, + "deliveryWebhookUrl": { + "title": "డెలివరీ webhook URL" + }, + "deliverySecret": { + "title": "డెలివరీ రహస్యం" + }, + "deliveryTimeoutMs": { + "title": "డెలివరీ టైమ్‌అవుట్ (ms)" + }, + "chatDelivery": { + "title": "చాట్‌లో డెలివరీ" + } + } + }, + "zh-CN": { + "name": "语音消息转写", + "description": "通过兼容 OpenAI 的语音转文字后端(自托管 Speaches/faster-whisper 或托管的 Groq/OpenAI)将传入的 WhatsApp 语音消息转写为文本,并向你的 webhook 投递 `message.transcription` 事件——让机器人和 AI 能够读取并回复语音。不在消息投递路径上;启用前保持禁用。", + "config": { + "sttBaseUrl": { + "title": "STT 基础 URL" + }, + "sttApiKey": { + "title": "STT API 密钥" + }, + "model": { + "title": "模型" + }, + "language": { + "title": "语言提示" + }, + "provider": { + "title": "提供商标签" + }, + "timeoutMs": { + "title": "STT 超时(毫秒)" + }, + "enabledMessageTypes": { + "title": "要转写的消息类型" + }, + "maxSizeBytes": { + "title": "最大音频大小(字节)" + }, + "maxPerHour": { + "title": "每小时每会话最大转写数" + }, + "deliveryWebhookUrl": { + "title": "投递 webhook URL" + }, + "deliverySecret": { + "title": "投递密钥" + }, + "deliveryTimeoutMs": { + "title": "投递超时(毫秒)" + }, + "chatDelivery": { + "title": "聊天内投递" + } + } + }, + "zh-HK": { + "name": "語音訊息轉寫", + "description": "透過兼容 OpenAI 的語音轉文字後端(自託管 Speaches/faster-whisper 或託管的 Groq/OpenAI)將傳入的 WhatsApp 語音訊息轉寫為文字,並向你的 webhook 投遞 `message.transcription` 事件——讓機器人及 AI 能夠讀取並回覆語音。不在訊息投遞路徑上;啟用前保持停用。", + "config": { + "sttBaseUrl": { + "title": "STT 基礎 URL" + }, + "sttApiKey": { + "title": "STT API 金鑰" + }, + "model": { + "title": "模型" + }, + "language": { + "title": "語言提示" + }, + "provider": { + "title": "提供者標籤" + }, + "timeoutMs": { + "title": "STT 逾時(毫秒)" + }, + "enabledMessageTypes": { + "title": "要轉寫的訊息類型" + }, + "maxSizeBytes": { + "title": "最大音訊大小(位元組)" + }, + "maxPerHour": { + "title": "每小時每工作階段最大轉寫數" + }, + "deliveryWebhookUrl": { + "title": "投遞 webhook URL" + }, + "deliverySecret": { + "title": "投遞密鑰" + }, + "deliveryTimeoutMs": { + "title": "投遞逾時(毫秒)" + }, + "chatDelivery": { + "title": "對話內投遞" + } + } } } }