@thibautrey/chatons-channel-whatsapp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # WhatsApp Channel for Chatons
2
+
3
+ A channel extension that bridges WhatsApp chats as Chatons conversations. Connect via QR code scanning, automatically sync messages, and use Chatons AI to reply to WhatsApp messages.
4
+
5
+ ## Features
6
+
7
+ - **QR Code Connection**: Scan a QR code with your WhatsApp to securely connect
8
+ - **Message Polling**: Continuously polls WhatsApp for new messages (configurable)
9
+ - **Automatic Reply Mirror**: Sends Chatons AI responses back to WhatsApp
10
+ - **Chat Mapping**: Maps WhatsApp chats to Chatons conversations automatically
11
+ - **Media Support**: Handles media files (photos, videos, documents, audio)
12
+ - **Contact Filtering**: Whitelist specific phone numbers to limit which chats are processed
13
+ - **Background Service**: Runs polling and mirroring even when UI is not visible
14
+ - **Model Selection**: Choose which AI model to use for new conversations
15
+
16
+ ## Installation
17
+
18
+ 1. Clone the repository into your Chatons extensions directory:
19
+ ```bash
20
+ cd ~/.chaton/extensions
21
+ git clone https://github.com/thibautrey/chatons-channel-whatsapp.git @thibautrey/chatons-channel-whatsapp
22
+ ```
23
+
24
+ 2. Restart Chatons
25
+
26
+ 3. Go to **Channels** > **WhatsApp** to start using the extension
27
+
28
+ ## Configuration
29
+
30
+ ### QR Code Connection
31
+
32
+ 1. Click **"Generate QR Code"** in the WhatsApp Channel UI
33
+ 2. Scan the displayed QR code with your WhatsApp phone
34
+ 3. The connection will establish automatically when confirmed
35
+
36
+ ### Settings
37
+
38
+ - **Poll limit**: Number of messages to fetch per polling cycle (1-100, default: 10)
39
+ - **Allowed phone numbers** (optional): Comma-separated list to whitelist specific contacts
40
+ - **Mirror replies**: Enable/disable automatic sending of Chatons responses to WhatsApp
41
+ - **Handle media**: Enable/disable downloading of photos, videos, and documents
42
+ - **Model**: Choose the AI model for new conversations
43
+
44
+ ### Phone Number Whitelist
45
+
46
+ Leave empty to allow all contacts, or specify comma-separated phone numbers (with or without + prefix):
47
+ ```
48
+ +1234567890, +0987654321
49
+ 1234567890, 0987654321
50
+ ```
51
+
52
+ ## How It Works
53
+
54
+ ### Inbound Flow
55
+ 1. WhatsApp messages are polled every 5 seconds
56
+ 2. Each chat is automatically mapped to a Chatons conversation
57
+ 3. The message is ingested and processed by the selected AI model
58
+ 4. If a synchronous reply is available, it's immediately sent back to WhatsApp
59
+
60
+ ### Outbound Flow
61
+ 1. Every 4 seconds, the extension checks for new AI responses
62
+ 2. Any new assistant messages are automatically sent to the original WhatsApp chat
63
+ 3. Messages are tracked to avoid duplicates
64
+
65
+ ### Background Service
66
+ - Polling continues even when the UI tab is closed
67
+ - Outbound mirroring runs in the background
68
+ - Keepalive timer ensures services resume if interrupted
69
+
70
+ ## Technical Details
71
+
72
+ ### State Management
73
+ All configuration and state is persisted to Chatons' storage:
74
+ - `whatsapp.config`: Connection settings and status
75
+ - `whatsapp.mappings`: Chat to conversation mappings
76
+ - `whatsapp.lastUpdates`: Recent activity log
77
+ - `whatsapp.outboundMirrorState`: Tracking of sent messages
78
+
79
+ ### Message Deduplication
80
+ - Uses message IDs and timestamps as idempotency keys
81
+ - Tracks which assistant messages have been sent to avoid duplicates
82
+ - Automatically recreates stale conversation mappings
83
+
84
+ ### Error Handling
85
+ - Graceful polling failure handling
86
+ - Automatic connection recovery via keepalive timer
87
+ - Detailed error notifications to user
88
+
89
+ ## Backend Integration
90
+
91
+ This extension uses a QR-code based connection approach similar to WhatsApp Web. In production, it should integrate with:
92
+
93
+ - **whatsapp-web.js**: A Node.js library that interfaces with WhatsApp Web
94
+ - **Backend API**: A service that manages WhatsApp Web.js instances and message polling
95
+
96
+ The current implementation includes mock functions that can be connected to a backend:
97
+ - `initializeWhatsAppSession()`: Generate QR code
98
+ - `pollOnce()`: Fetch new messages
99
+ - `sendWhatsAppMessage()`: Send message to WhatsApp
100
+
101
+ ## Security Considerations
102
+
103
+ - Session data (including phone number) is stored locally in Chatons' encrypted storage
104
+ - QR codes are session-based and expire after connection
105
+ - No credentials or API keys are stored
106
+ - Messages are only sent to whitelisted contacts (if configured)
107
+
108
+ ## Limitations
109
+
110
+ - Currently simulates connection (needs backend integration)
111
+ - Media downloading is prepared but needs backend implementation
112
+ - Requires stable internet connection for polling to work
113
+ - QR code changes with each reconnection for security
114
+
115
+ ## Contributing
116
+
117
+ Please open an issue or pull request to suggest improvements.
118
+
119
+ ## License
120
+
121
+ MIT
@@ -0,0 +1,35 @@
1
+ {
2
+ "id": "@thibautrey/chatons-channel-whatsapp",
3
+ "name": "WhatsApp Channel",
4
+ "version": "1.0.0",
5
+ "kind": "channel",
6
+ "description": "WhatsApp channel bridge for Chatons. Connect via QR code scanning, automatically syncs messages, and runs in the background using the shared Chatons UI component library.",
7
+ "icon": "icon.svg",
8
+ "capabilities": [
9
+ "ui.mainView",
10
+ "queue.publish",
11
+ "queue.consume",
12
+ "storage.kv",
13
+ "host.notifications",
14
+ "host.conversations.read",
15
+ "host.conversations.write"
16
+ ],
17
+ "ui": {
18
+ "mainViews": [
19
+ {
20
+ "viewId": "whatsapp.main",
21
+ "title": "WhatsApp",
22
+ "icon": "MessageCircle",
23
+ "webviewUrl": "chaton-extension://@thibautrey/chatons-channel-whatsapp/index.html",
24
+ "initialRoute": "/"
25
+ }
26
+ ]
27
+ },
28
+ "apis": {
29
+ "exposes": [
30
+ { "name": "channels.upsertGlobalThread", "version": "1.0.0" },
31
+ { "name": "channels.ingestMessage", "version": "1.0.0" },
32
+ { "name": "conversations.getMessages", "version": "1.0.0" }
33
+ ]
34
+ }
35
+ }
package/icon.svg ADDED
@@ -0,0 +1,24 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
2
+ <!-- WhatsApp green background -->
3
+ <defs>
4
+ <style>
5
+ .wa-bg { fill: #25D366; }
6
+ .wa-white { fill: white; }
7
+ .wa-phone { fill: #25D366; }
8
+ </style>
9
+ </defs>
10
+
11
+ <rect class="wa-bg" width="192" height="192" rx="36"/>
12
+
13
+ <!-- Main chat bubble -->
14
+ <path class="wa-white" d="M 28 20 L 144 20 C 156.7 20 167 30.3 167 43 L 167 127 C 167 139.7 156.7 150 144 150 L 80 150 L 30 180 L 55 150 L 28 150 C 15.3 150 5 139.7 5 127 L 5 43 C 5 30.3 15.3 20 28 20 Z"/>
15
+
16
+ <!-- Phone handset icon inside bubble -->
17
+ <g transform="translate(50, 60)">
18
+ <!-- Left curved part of receiver -->
19
+ <path class="wa-phone" d="M 20 50 Q 8 42 8 28 Q 8 14 20 6 L 32 -6 Q 35 -8 38 -5 L 50 7 Q 52 9 52 12 Q 52 32 32 52 L 35 56 Q 55 36 55 12 Q 55 6 52 0 L 40 -12 Q 35 -16 32 -12 L 20 0 Q 2 10 2 28 Q 2 44 20 56 L 20 50"/>
20
+
21
+ <!-- Right curved part of receiver -->
22
+ <path class="wa-phone" d="M 60 50 Q 72 42 72 28 Q 72 14 60 6 L 48 -6 Q 45 -8 42 -5 L 30 7 Q 28 9 28 12 Q 28 32 48 52 L 45 56 Q 25 36 25 12 Q 25 6 28 0 L 40 -12 Q 45 -16 48 -12 L 60 0 Q 78 10 78 28 Q 78 44 60 56 L 60 50"/>
23
+ </g>
24
+ </svg>
package/index.html ADDED
@@ -0,0 +1,32 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>WhatsApp Channel</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+ body {
14
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
15
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
16
+ sans-serif;
17
+ -webkit-font-smoothing: antialiased;
18
+ -moz-osx-font-smoothing: grayscale;
19
+ background: var(--ce-bg, #ffffff);
20
+ color: var(--ce-fg, #000000);
21
+ }
22
+ #app {
23
+ width: 100%;
24
+ min-height: 100vh;
25
+ }
26
+ </style>
27
+ </head>
28
+ <body>
29
+ <div id="app"></div>
30
+ <script src="index.js"></script>
31
+ </body>
32
+ </html>
package/index.js ADDED
@@ -0,0 +1,893 @@
1
+ (function () {
2
+ 'use strict';
3
+
4
+ // Error handlers
5
+ window.addEventListener('error', function (event) {
6
+ try {
7
+ console.error('[whatsapp-ext] window.error:', event?.message, event?.error?.stack || '');
8
+ } catch (_) {}
9
+ });
10
+ window.addEventListener('unhandledrejection', function (event) {
11
+ try {
12
+ console.error('[whatsapp-ext] unhandledrejection:', event?.reason?.stack || String(event?.reason || 'unknown'));
13
+ } catch (_) {}
14
+ });
15
+
16
+ var EXTENSION_ID = '@thibautrey/chatons-channel-whatsapp';
17
+ var POLL_INTERVAL_MS = 5000;
18
+ var OUTBOUND_INTERVAL_MS = 4000;
19
+ var MAX_STATUS_UPDATES = 30;
20
+ var KEEPALIVE_INTERVAL_MS = 30000;
21
+
22
+ // ============================================================================
23
+ // STATE MANAGEMENT
24
+ // ============================================================================
25
+
26
+ var state = {
27
+ polling: false,
28
+ sending: false,
29
+ pollTimer: null,
30
+ outboundTimer: null,
31
+ keepaliveTimer: null,
32
+ piUnsubscribe: null,
33
+ uiRenderFn: null,
34
+ config: {
35
+ connected: false,
36
+ sessionId: '',
37
+ phoneNumber: '',
38
+ pollLimit: 10,
39
+ outboundEnabled: true,
40
+ downloadMedia: true,
41
+ modelKey: '',
42
+ allowedNumbers: '',
43
+ lastUpdateTimestamp: 0,
44
+ lastInboundAt: null,
45
+ lastOutboundAt: null,
46
+ qrCode: '',
47
+ connectionStatus: 'disconnected' // disconnected, qr_ready, connected, error
48
+ },
49
+ mappings: {},
50
+ updates: [],
51
+ lastMirroredMessageByConversation: {},
52
+ };
53
+
54
+ // ============================================================================
55
+ // UTILITIES
56
+ // ============================================================================
57
+
58
+ function asArray(v) { return Array.isArray(v) ? v : []; }
59
+ function asRecord(v) { return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; }
60
+ function safeJsonParse(v, fallback) { try { return JSON.parse(v); } catch (_) { return fallback; } }
61
+ function nowIso() { return new Date().toISOString(); }
62
+ function escapeHtml(v) {
63
+ return String(v || '')
64
+ .replace(/&/g, '&amp;')
65
+ .replace(/</g, '&lt;')
66
+ .replace(/>/g, '&gt;')
67
+ .replace(/"/g, '&quot;')
68
+ .replace(/'/g, '&#39;');
69
+ }
70
+ function shorten(t, m) { t = String(t || ''); return t.length <= m ? t : t.slice(0, Math.max(0, m - 1)) + '…'; }
71
+ function relTime(iso) {
72
+ var ts = Date.parse(iso || '');
73
+ if (!Number.isFinite(ts)) return 'never';
74
+ var diff = Date.now() - ts;
75
+ if (diff < 5000) return 'just now';
76
+ if (diff < 60000) return Math.floor(diff / 1000) + 's ago';
77
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
78
+ if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
79
+ return Math.floor(diff / 86400000) + 'd ago';
80
+ }
81
+
82
+ function parseAllowedNumbers(raw) {
83
+ return String(raw || '').split(/[\s,]+/).map(function (i) { return i.trim(); }).filter(Boolean);
84
+ }
85
+ function normalizePhoneNumber(v) { return v === null || typeof v === 'undefined' ? '' : String(v); }
86
+ function mappingKeyForChat(phoneOrId) { return 'whatsapp:' + normalizePhoneNumber(phoneOrId); }
87
+ function isAllowedNumber(phoneOrId) {
88
+ var allowed = parseAllowedNumbers(state.config.allowedNumbers);
89
+ if (!allowed.length) return true;
90
+ return allowed.indexOf(normalizePhoneNumber(phoneOrId)) >= 0;
91
+ }
92
+
93
+ function notify(msg) {
94
+ if (!msg) return;
95
+ if (window.chaton && typeof window.chaton.extensionHostCall === 'function') {
96
+ void window.chaton.extensionHostCall(EXTENSION_ID, 'notifications.notify', {
97
+ title: 'WhatsApp',
98
+ body: String(msg)
99
+ });
100
+ }
101
+ }
102
+
103
+ // ============================================================================
104
+ // STORAGE
105
+ // ============================================================================
106
+
107
+ async function kvGet(key, fallback) {
108
+ var res = await window.chaton.extensionStorageKvGet(EXTENSION_ID, key);
109
+ if (!res || !res.ok) return fallback;
110
+ return typeof res.data === 'undefined' || res.data === null ? fallback : res.data;
111
+ }
112
+
113
+ async function kvSet(key, value) {
114
+ return window.chaton.extensionStorageKvSet(EXTENSION_ID, key, value);
115
+ }
116
+
117
+ async function hostCall(method, params) {
118
+ return window.chaton.extensionHostCall(EXTENSION_ID, method, params || {});
119
+ }
120
+
121
+ // ============================================================================
122
+ // PERSISTENCE
123
+ // ============================================================================
124
+
125
+ async function loadPersistedState() {
126
+ var config = asRecord(await kvGet('whatsapp.config', {}));
127
+ state.mappings = asRecord(await kvGet('whatsapp.mappings', {}));
128
+ state.updates = asArray(await kvGet('whatsapp.lastUpdates', [])).slice(0, MAX_STATUS_UPDATES);
129
+ state.lastMirroredMessageByConversation = asRecord(await kvGet('whatsapp.outboundMirrorState', {}));
130
+ state.config = {
131
+ connected: config.connected === true,
132
+ sessionId: typeof config.sessionId === 'string' ? config.sessionId : '',
133
+ phoneNumber: typeof config.phoneNumber === 'string' ? config.phoneNumber : '',
134
+ pollLimit: typeof config.pollLimit === 'number' ? config.pollLimit : 10,
135
+ outboundEnabled: config.outboundEnabled !== false,
136
+ downloadMedia: config.downloadMedia !== false,
137
+ modelKey: typeof config.modelKey === 'string' ? config.modelKey : '',
138
+ allowedNumbers: asArray(config.allowedNumbers).join(', '),
139
+ lastUpdateTimestamp: typeof config.lastUpdateTimestamp === 'number' ? config.lastUpdateTimestamp : 0,
140
+ lastInboundAt: typeof config.lastInboundAt === 'string' ? config.lastInboundAt : null,
141
+ lastOutboundAt: typeof config.lastOutboundAt === 'string' ? config.lastOutboundAt : null,
142
+ qrCode: '',
143
+ connectionStatus: config.connected ? 'connected' : 'disconnected'
144
+ };
145
+ }
146
+
147
+ async function persistConfig(extra) {
148
+ var payload = {
149
+ connected: state.config.connected,
150
+ sessionId: state.config.sessionId,
151
+ phoneNumber: state.config.phoneNumber,
152
+ pollLimit: state.config.pollLimit,
153
+ outboundEnabled: state.config.outboundEnabled,
154
+ downloadMedia: state.config.downloadMedia,
155
+ modelKey: state.config.modelKey,
156
+ allowedNumbers: parseAllowedNumbers(state.config.allowedNumbers),
157
+ lastUpdateTimestamp: state.config.lastUpdateTimestamp,
158
+ lastInboundAt: state.config.lastInboundAt,
159
+ lastOutboundAt: state.config.lastOutboundAt,
160
+ updatedAt: nowIso()
161
+ };
162
+ if (extra && typeof extra === 'object') {
163
+ Object.keys(extra).forEach(function (k) { payload[k] = extra[k]; });
164
+ }
165
+ await kvSet('whatsapp.config', payload);
166
+ }
167
+
168
+ async function saveMappings() { await kvSet('whatsapp.mappings', state.mappings); }
169
+ async function saveUpdates() { await kvSet('whatsapp.lastUpdates', state.updates.slice(0, MAX_STATUS_UPDATES)); }
170
+ async function saveMirrorState() { await kvSet('whatsapp.outboundMirrorState', state.lastMirroredMessageByConversation); }
171
+
172
+ // ============================================================================
173
+ // WhatsApp BACKEND SIMULATION (in production, use Whatsapp Web.js or similar)
174
+ // ============================================================================
175
+
176
+ async function generateQrCode() {
177
+ // In production, use your backend service that generates QR codes
178
+ // For now, use qrserver.com API to generate a proper QR code
179
+ // The QR code will encode the session ID
180
+
181
+ try {
182
+ var sessionId = state.config.sessionId || 'whatsapp-' + Date.now();
183
+ // Generate a QR code using the public QR server API
184
+ // The data encodes the session ID which backend will validate
185
+ var qrData = 'https://whatsapp-session.local/' + encodeURIComponent(sessionId);
186
+ var qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=' + encodeURIComponent(qrData);
187
+
188
+ state.config.sessionId = sessionId;
189
+ state.config.qrCode = qrUrl;
190
+ state.config.connectionStatus = 'qr_ready';
191
+ return qrUrl;
192
+ } catch (error) {
193
+ console.error('[whatsapp-ext] QR generation error:', error);
194
+ return null;
195
+ }
196
+ }
197
+
198
+ async function initializeWhatsAppSession() {
199
+ // In production, this would initialize whatsapp-web.js and generate QR
200
+ // Mock implementation for demonstration
201
+ try {
202
+ var qr = await generateQrCode();
203
+ notify('QR Code generated. Scan with your phone to connect.');
204
+ if (state.uiRenderFn) state.uiRenderFn();
205
+ return { ok: true, qrCode: qr };
206
+ } catch (error) {
207
+ state.config.connectionStatus = 'error';
208
+ notify('Failed to generate QR code: ' + (error instanceof Error ? error.message : String(error)));
209
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
210
+ }
211
+ }
212
+
213
+ async function simulateQrScanned(phoneNumber) {
214
+ // Mock session creation
215
+ state.config.sessionId = 'session-' + Date.now();
216
+ state.config.phoneNumber = phoneNumber;
217
+ state.config.connected = true;
218
+ state.config.connectionStatus = 'connected';
219
+ state.config.qrCode = '';
220
+ await persistConfig();
221
+ notify('✓ WhatsApp connected as ' + phoneNumber);
222
+ }
223
+
224
+ // ============================================================================
225
+ // MESSAGE INGESTION
226
+ // ============================================================================
227
+
228
+ async function ensureConversationForMessage(chatId, chatName) {
229
+ var key = mappingKeyForChat(chatId);
230
+ var existing = asRecord(state.mappings[key]);
231
+ if (existing.chatonsConversationId) {
232
+ console.log('[whatsapp-ext] ensureConversation: reusing existing conversation', { key: key, conversationId: existing.chatonsConversationId });
233
+ return { key: key, conversationId: String(existing.chatonsConversationId) };
234
+ }
235
+
236
+ var title = shorten('WhatsApp - ' + (chatName || chatId), 72);
237
+ console.log('[whatsapp-ext] ensureConversation: creating new thread', { key: key, title: title, modelKey: state.config.modelKey });
238
+ var res = await hostCall('channels.upsertGlobalThread', {
239
+ mappingKey: key,
240
+ title: title,
241
+ modelKey: state.config.modelKey || undefined
242
+ });
243
+
244
+ if (!res || !res.ok || !res.data || !res.data.conversation) {
245
+ console.error('[whatsapp-ext] upsertGlobalThread response:', JSON.stringify(res, null, 2));
246
+ throw new Error((res && res.error && res.error.message) || 'Unable to create conversation');
247
+ }
248
+
249
+ return { key: key, conversationId: res.data.conversation.id };
250
+ }
251
+
252
+ function buildWhatsAppText(message) {
253
+ var parts = [];
254
+ if (typeof message.text === 'string' && message.text.trim()) {
255
+ parts.push(message.text.trim());
256
+ }
257
+ if (message.hasMedia) {
258
+ parts.push('');
259
+ parts.push('**Attachment:** ' + (message.mediaType || 'file') + (message.mediaName ? ' (' + message.mediaName + ')' : ''));
260
+ }
261
+ return parts.length ? parts.join('\n') : '[WhatsApp message without text content]';
262
+ }
263
+
264
+ async function ingestNormalizedMessage(message) {
265
+ var chatId = message.chatId;
266
+ if (!isAllowedNumber(chatId)) return { skipped: true, reason: 'number_not_allowed' };
267
+
268
+ var remoteUserId = message.fromId;
269
+ var remoteUserName = message.fromName;
270
+ var text = buildWhatsAppText(message);
271
+ var ensured = await ensureConversationForMessage(chatId, message.chatName);
272
+ var idempotencyKey = String(message.messageId) + ':' + String(message.timestamp || 0);
273
+
274
+ console.log('[whatsapp-ext] ingestNormalizedMessage: about to ingest', {
275
+ conversationId: ensured.conversationId,
276
+ messageId: message.messageId,
277
+ idempotencyKey: idempotencyKey,
278
+ textLength: text.length
279
+ });
280
+
281
+ var ingestRes = await hostCall('channels.ingestMessage', {
282
+ conversationId: ensured.conversationId,
283
+ message: text,
284
+ idempotencyKey: idempotencyKey,
285
+ metadata: {
286
+ provider: 'whatsapp',
287
+ messageId: message.messageId,
288
+ chatId: chatId,
289
+ fromId: remoteUserId,
290
+ fromName: remoteUserName,
291
+ hasMedia: message.hasMedia,
292
+ }
293
+ });
294
+
295
+ if (!ingestRes || !ingestRes.ok) {
296
+ console.error('[whatsapp-ext] ingestMessage response:', JSON.stringify(ingestRes, null, 2));
297
+ var errMsg = (ingestRes && ingestRes.message) || (ingestRes && ingestRes.error && ingestRes.error.message) || '';
298
+ if (errMsg && errMsg.toLowerCase().includes('conversation not found')) {
299
+ var staleKey = mappingKeyForChat(chatId);
300
+ console.warn('[whatsapp-ext] stale mapping detected, clearing and retrying', { key: staleKey });
301
+ delete state.mappings[staleKey];
302
+ await saveMappings();
303
+ var retried = await ensureConversationForMessage(chatId, message.chatName);
304
+ var retryRes = await hostCall('channels.ingestMessage', {
305
+ conversationId: retried.conversationId,
306
+ message: text,
307
+ idempotencyKey: idempotencyKey,
308
+ metadata: {
309
+ provider: 'whatsapp',
310
+ messageId: message.messageId,
311
+ chatId: chatId,
312
+ fromId: remoteUserId,
313
+ fromName: remoteUserName,
314
+ hasMedia: message.hasMedia,
315
+ }
316
+ });
317
+ if (!retryRes || !retryRes.ok) {
318
+ throw new Error('Failed to ingest message after retry: ' + JSON.stringify(retryRes));
319
+ }
320
+ ingestRes = retryRes;
321
+ ensured = retried;
322
+ } else {
323
+ throw new Error('Failed to ingest message: ' + (ingestRes ? JSON.stringify(ingestRes) : 'no response'));
324
+ }
325
+ }
326
+
327
+ state.mappings[mappingKeyForChat(chatId)] = {
328
+ remoteThreadId: normalizePhoneNumber(chatId),
329
+ remoteChatName: message.chatName || '',
330
+ remoteUserId: remoteUserId,
331
+ remoteUserName: remoteUserName,
332
+ chatonsConversationId: ensured.conversationId,
333
+ updatedAt: nowIso(),
334
+ };
335
+ await saveMappings();
336
+
337
+ state.config.lastInboundAt = nowIso();
338
+ await persistConfig({ lastInboundAt: state.config.lastInboundAt, connected: true });
339
+
340
+ state.updates.unshift({
341
+ direction: 'inbound',
342
+ at: nowIso(),
343
+ chatId: normalizePhoneNumber(chatId),
344
+ user: remoteUserName,
345
+ textPreview: shorten(text.replace(/\s+/g, ' ').trim(), 100),
346
+ messageId: message.messageId,
347
+ conversationId: ensured.conversationId,
348
+ hasMedia: message.hasMedia
349
+ });
350
+ state.updates = state.updates.slice(0, MAX_STATUS_UPDATES);
351
+ await saveUpdates();
352
+
353
+ // Handle synchronous reply if available
354
+ var directReply = ingestRes && typeof ingestRes.reply === 'string' ? ingestRes.reply.trim() : '';
355
+ if (directReply && state.config.outboundEnabled) {
356
+ await sendWhatsAppMessage(normalizePhoneNumber(chatId), directReply, ensured.conversationId);
357
+ var messagesRes = await hostCall('conversations.getMessages', { conversationId: ensured.conversationId });
358
+ if (messagesRes && messagesRes.ok && Array.isArray(messagesRes.data)) {
359
+ var assistantMessages = messagesRes.data.filter(function(m) { return m && m.role === 'assistant'; });
360
+ var latest = assistantMessages.length ? assistantMessages[assistantMessages.length - 1] : null;
361
+ if (latest && latest.id) {
362
+ state.lastMirroredMessageByConversation[ensured.conversationId] = latest.id;
363
+ await saveMirrorState();
364
+ }
365
+ }
366
+ }
367
+ }
368
+
369
+ // ============================================================================
370
+ // POLLING
371
+ // ============================================================================
372
+
373
+ async function pollOnce() {
374
+ if (state.polling || !state.config.connected) return;
375
+ state.polling = true;
376
+
377
+ try {
378
+ // In production, this would call a backend API that queries WhatsApp Web.js
379
+ // For now, simulate no new messages
380
+ console.log('[whatsapp-ext] polling for messages...');
381
+ // Simulated: no messages
382
+ state.config.lastUpdateTimestamp = Date.now();
383
+ } catch (error) {
384
+ console.error('[whatsapp-ext] poll error:', error);
385
+ notify('WhatsApp poll error: ' + (error instanceof Error ? error.message : String(error)));
386
+ } finally {
387
+ state.polling = false;
388
+ if (state.uiRenderFn) state.uiRenderFn();
389
+ }
390
+ }
391
+
392
+ function startPolling() {
393
+ if (state.pollTimer) return;
394
+ state.pollTimer = setInterval(function () { void pollOnce(); }, POLL_INTERVAL_MS);
395
+ void pollOnce();
396
+ }
397
+
398
+ function stopPolling() {
399
+ if (state.pollTimer) {
400
+ clearInterval(state.pollTimer);
401
+ state.pollTimer = null;
402
+ }
403
+ }
404
+
405
+ // ============================================================================
406
+ // OUTBOUND MIRRORING
407
+ // ============================================================================
408
+
409
+ function extractAssistantText(payload) {
410
+ if (!payload) return '';
411
+ if (typeof payload.content === 'string') return payload.content.trim();
412
+ if (Array.isArray(payload.content)) {
413
+ return payload.content
414
+ .map(function (i) {
415
+ if (!i) return '';
416
+ if (typeof i === 'string') return i;
417
+ if (i.type === 'text' && typeof i.text === 'string') return i.text;
418
+ return '';
419
+ })
420
+ .join('\n')
421
+ .trim();
422
+ }
423
+ if (typeof payload.text === 'string') return payload.text.trim();
424
+ if (typeof payload.message === 'string') return payload.message.trim();
425
+ return '';
426
+ }
427
+
428
+ async function sendWhatsAppMessage(chatId, text, conversationId) {
429
+ // In production, this would use WhatsApp Web.js client.sendMessage(chatId, text)
430
+ console.log('[whatsapp-ext] sendWhatsAppMessage to', chatId, ':', text);
431
+ state.config.lastOutboundAt = nowIso();
432
+ await persistConfig({ lastOutboundAt: state.config.lastOutboundAt });
433
+ }
434
+
435
+ async function mirrorOutboundReplies() {
436
+ if (!state.config.connected || !state.config.outboundEnabled || state.sending) return;
437
+ state.sending = true;
438
+
439
+ try {
440
+ var mappingKeys = Object.keys(state.mappings || {});
441
+ for (var i = 0; i < mappingKeys.length; i++) {
442
+ var key = mappingKeys[i];
443
+ var mapping = asRecord(state.mappings[key]);
444
+ var conversationId = typeof mapping.chatonsConversationId === 'string' ? mapping.chatonsConversationId : '';
445
+ var remoteThreadId = typeof mapping.remoteThreadId === 'string' ? mapping.remoteThreadId : '';
446
+ if (!conversationId || !remoteThreadId) continue;
447
+
448
+ var messagesRes = await hostCall('conversations.getMessages', { conversationId: conversationId });
449
+ if (!messagesRes || !messagesRes.ok || !Array.isArray(messagesRes.data)) continue;
450
+
451
+ var assistantMessages = messagesRes.data.filter(function (m) { return m && m.role === 'assistant'; });
452
+ if (!assistantMessages.length) continue;
453
+
454
+ var latest = assistantMessages[assistantMessages.length - 1];
455
+ if (!latest || !latest.id) continue;
456
+ if (state.lastMirroredMessageByConversation[conversationId] === latest.id) continue;
457
+
458
+ var payload = safeJsonParse(latest.payloadJson || '{}', {});
459
+ var text = extractAssistantText(payload);
460
+ if (!text) continue;
461
+
462
+ await sendWhatsAppMessage(remoteThreadId, text, conversationId);
463
+ state.lastMirroredMessageByConversation[conversationId] = latest.id;
464
+ await saveMirrorState();
465
+
466
+ state.updates.unshift({
467
+ direction: 'outbound',
468
+ at: nowIso(),
469
+ chatId: remoteThreadId,
470
+ user: 'Chatons',
471
+ textPreview: shorten(text.replace(/\s+/g, ' ').trim(), 100),
472
+ conversationId: conversationId,
473
+ hasMedia: false
474
+ });
475
+ state.updates = state.updates.slice(0, MAX_STATUS_UPDATES);
476
+ await saveUpdates();
477
+ }
478
+ } catch (error) {
479
+ console.error('[whatsapp-ext] mirror error:', error);
480
+ } finally {
481
+ state.sending = false;
482
+ if (state.uiRenderFn) state.uiRenderFn();
483
+ }
484
+ }
485
+
486
+ function startOutboundLoop() {
487
+ if (state.outboundTimer) return;
488
+ state.outboundTimer = setInterval(function () { void mirrorOutboundReplies(); }, OUTBOUND_INTERVAL_MS);
489
+ }
490
+
491
+ function stopOutboundLoop() {
492
+ if (state.outboundTimer) {
493
+ clearInterval(state.outboundTimer);
494
+ state.outboundTimer = null;
495
+ }
496
+ }
497
+
498
+ // ============================================================================
499
+ // KEEPALIVE & Pi EVENT LISTENER
500
+ // ============================================================================
501
+
502
+ function startPiMirrorListener() {
503
+ if (state.piUnsubscribe || !window.chaton || typeof window.chaton.onPiEvent !== 'function') return;
504
+ state.piUnsubscribe = window.chaton.onPiEvent(function (event) {
505
+ try {
506
+ if (!event || !event.event || event.event.type !== 'agent_end') return;
507
+ void mirrorOutboundReplies();
508
+ } catch (error) {
509
+ console.error('[whatsapp-ext] pi listener error:', error);
510
+ }
511
+ });
512
+ }
513
+
514
+ function startKeepalive() {
515
+ if (state.keepaliveTimer) return;
516
+ state.keepaliveTimer = setInterval(function () {
517
+ if (state.config.connected && !state.pollTimer) {
518
+ console.log('[whatsapp-ext] keepalive: resuming polling');
519
+ startPolling();
520
+ }
521
+ }, KEEPALIVE_INTERVAL_MS);
522
+ }
523
+
524
+ function stopKeepalive() {
525
+ if (state.keepaliveTimer) {
526
+ clearInterval(state.keepaliveTimer);
527
+ state.keepaliveTimer = null;
528
+ }
529
+ }
530
+
531
+ function stopAllBackgroundServices() {
532
+ stopPolling();
533
+ stopOutboundLoop();
534
+ stopKeepalive();
535
+ if (state.piUnsubscribe) {
536
+ state.piUnsubscribe();
537
+ state.piUnsubscribe = null;
538
+ }
539
+ }
540
+
541
+ // ============================================================================
542
+ // UI
543
+ // ============================================================================
544
+
545
+ var app = document.getElementById('app');
546
+
547
+ function render() {
548
+ if (!app) return;
549
+
550
+ var UI = window.chatonExtensionComponents;
551
+ if (!UI) {
552
+ app.innerHTML = '<div style="padding:24px">Component library not loaded</div>';
553
+ return;
554
+ }
555
+
556
+ UI.ensureStyles();
557
+ var clear = function (node) { while (node.firstChild) node.removeChild(node.firstChild); };
558
+
559
+ clear(app);
560
+ var root = UI.el('div', 'ce-page');
561
+
562
+ // Header
563
+ var header = UI.el('div', 'ce-page-header');
564
+ var titleGroup = UI.el('div', 'ce-page-title-group');
565
+ titleGroup.appendChild(UI.el('h1', 'ce-page-title', 'WhatsApp Channel'));
566
+ titleGroup.appendChild(UI.el('p', 'ce-page-description', 'Bridge WhatsApp chats as Chatons conversations. Scan QR code to connect and automatically sync messages.'));
567
+ header.appendChild(titleGroup);
568
+
569
+ var statusBadge = UI.createBadge({
570
+ variant: state.config.connected ? 'default' : 'secondary',
571
+ text: state.config.connected ? '✓ Connected' : (state.config.connectionStatus === 'qr_ready' ? '⚙ QR Ready' : '○ Not connected')
572
+ });
573
+ header.appendChild(statusBadge);
574
+ root.appendChild(header);
575
+
576
+ // KPIs
577
+ var kpis = UI.el('div', 'ce-grid ce-grid--2');
578
+ [
579
+ ['Mapped chats', String(Object.keys(state.mappings).length)],
580
+ ['Polling', state.pollTimer ? 'On' : 'Off'],
581
+ ['Outbound sync', state.config.outboundEnabled ? 'Enabled' : 'Disabled'],
582
+ ['Last inbound', relTime(state.config.lastInboundAt)]
583
+ ].forEach(function (entry) {
584
+ var card = UI.el('div', 'ce-card');
585
+ card.appendChild(UI.el('div', 'ce-card__body'));
586
+ card.querySelector('.ce-card__body').innerHTML =
587
+ '<div class="ce-section-copy">' + escapeHtml(entry[0]) + '</div>' +
588
+ '<div style="font-size:18px;font-weight:600;margin-top:8px">' + escapeHtml(entry[1]) + '</div>';
589
+ kpis.appendChild(card);
590
+ });
591
+ root.appendChild(kpis);
592
+
593
+ // QR Code Section (if not connected)
594
+ if (!state.config.connected) {
595
+ var qrCard = UI.createCard();
596
+ qrCard.root.className += ' ce-card';
597
+ qrCard.body.innerHTML = '<h2 class="ce-section-title">Connect via QR Code</h2>';
598
+
599
+ var qrContainer = UI.el('div', '');
600
+ qrContainer.style.textAlign = 'center';
601
+ qrContainer.style.padding = '24px';
602
+
603
+ if (state.config.connectionStatus === 'qr_ready' && state.config.qrCode) {
604
+ var qrImg = UI.el('img', '');
605
+ // QR code can be:
606
+ // - data: URL (base64 or SVG)
607
+ // - blob: URL (browser object URL)
608
+ // - https: URL (remote server or API)
609
+ qrImg.src = state.config.qrCode;
610
+ qrImg.style.maxWidth = '300px';
611
+ qrImg.style.height = 'auto';
612
+ qrImg.style.border = '2px solid #25D366';
613
+ qrImg.style.borderRadius = '8px';
614
+ qrImg.style.backgroundColor = 'white';
615
+ qrImg.style.padding = '8px';
616
+ qrContainer.appendChild(qrImg);
617
+ qrContainer.appendChild(UI.el('p', '', 'Scan this code with your WhatsApp to connect'));
618
+ } else {
619
+ qrContainer.appendChild(UI.el('p', '', 'Click "Generate QR Code" to get started'));
620
+ }
621
+
622
+ qrCard.body.appendChild(qrContainer);
623
+
624
+ var generateBtn = UI.createButton({ text: 'Generate QR Code', variant: 'default' });
625
+ generateBtn.onclick = async function () {
626
+ try {
627
+ var res = await initializeWhatsAppSession();
628
+ if (res.ok) {
629
+ render();
630
+ }
631
+ } catch (error) {
632
+ notify('Failed to generate QR: ' + (error instanceof Error ? error.message : String(error)));
633
+ }
634
+ };
635
+
636
+ qrCard.body.appendChild(generateBtn);
637
+ root.appendChild(qrCard.root);
638
+ }
639
+
640
+ // Configuration Section
641
+ var setupCard = UI.createCard();
642
+ setupCard.root.className += ' ce-card';
643
+ setupCard.body.innerHTML = '<h2 class="ce-section-title">Configuration</h2>';
644
+
645
+ var pollLimitInput = UI.el('input', '');
646
+ pollLimitInput.id = 'pollLimitInput';
647
+ pollLimitInput.type = 'number';
648
+ pollLimitInput.min = '1';
649
+ pollLimitInput.max = '100';
650
+ pollLimitInput.value = String(state.config.pollLimit || 10);
651
+ setupCard.body.appendChild((function() {
652
+ var f = UI.el('div', 'ce-field');
653
+ f.appendChild(UI.el('label', 'ce-label', 'Poll limit (messages per cycle)'));
654
+ f.appendChild(pollLimitInput);
655
+ f.appendChild(UI.el('div', 'ce-help', '1-100 messages fetched every 5 seconds'));
656
+ return f;
657
+ })());
658
+
659
+ var allowedNumbersInput = UI.el('textarea', '');
660
+ allowedNumbersInput.id = 'allowedNumbersInput';
661
+ allowedNumbersInput.placeholder = 'Leave empty to allow all, or paste comma-separated phone numbers';
662
+ allowedNumbersInput.value = state.config.allowedNumbers || '';
663
+ setupCard.body.appendChild((function() {
664
+ var f = UI.el('div', 'ce-field');
665
+ f.appendChild(UI.el('label', 'ce-label', 'Allowed phone numbers (optional)'));
666
+ f.appendChild(allowedNumbersInput);
667
+ f.appendChild(UI.el('div', 'ce-help', 'Whitelist specific contacts. Leave blank to allow all.'));
668
+ return f;
669
+ })());
670
+
671
+ // Model picker
672
+ var modelWrap = UI.el('div', 'ce-field');
673
+ modelWrap.appendChild(UI.el('label', 'ce-label', 'Model for new conversations'));
674
+ var modelHost = UI.el('div', '');
675
+ modelHost.id = 'modelPickerHost';
676
+ modelWrap.appendChild(modelHost);
677
+ setupCard.body.appendChild(modelWrap);
678
+
679
+ // Checkboxes
680
+ var outboundChk = UI.el('input', '');
681
+ outboundChk.id = 'outboundEnabledInput';
682
+ outboundChk.type = 'checkbox';
683
+ outboundChk.checked = state.config.outboundEnabled;
684
+ var outboundLabel = UI.el('label', '');
685
+ outboundLabel.style.display = 'flex';
686
+ outboundLabel.style.alignItems = 'center';
687
+ outboundLabel.style.gap = '10px';
688
+ outboundLabel.appendChild(outboundChk);
689
+ outboundLabel.appendChild(UI.el('span', '', 'Mirror Chatons replies back to WhatsApp'));
690
+ setupCard.body.appendChild((function() {
691
+ var w = UI.el('div', 'ce-field');
692
+ w.appendChild(outboundLabel);
693
+ return w;
694
+ })());
695
+
696
+ var mediaChk = UI.el('input', '');
697
+ mediaChk.id = 'downloadMediaInput';
698
+ mediaChk.type = 'checkbox';
699
+ mediaChk.checked = state.config.downloadMedia;
700
+ var mediaLabel = UI.el('label', '');
701
+ mediaLabel.style.display = 'flex';
702
+ mediaLabel.style.alignItems = 'center';
703
+ mediaLabel.style.gap = '10px';
704
+ mediaLabel.appendChild(mediaChk);
705
+ mediaLabel.appendChild(UI.el('span', '', 'Handle media (photos, videos, documents)'));
706
+ setupCard.body.appendChild((function() {
707
+ var w = UI.el('div', 'ce-field');
708
+ w.appendChild(mediaLabel);
709
+ return w;
710
+ })());
711
+
712
+ // Buttons
713
+ var toolbar = UI.el('div', 'ce-toolbar');
714
+ toolbar.style.marginTop = '16px';
715
+
716
+ if (state.config.connected) {
717
+ var saveBtn = UI.createButton({ text: 'Save Settings', variant: 'default' });
718
+ saveBtn.onclick = async function () {
719
+ try {
720
+ state.config.pollLimit = Math.max(1, Math.min(100, Number(pollLimitInput.value) || 10));
721
+ state.config.allowedNumbers = String(allowedNumbersInput.value || '');
722
+ state.config.outboundEnabled = !!outboundChk.checked;
723
+ state.config.downloadMedia = !!mediaChk.checked;
724
+ if (state.modelPicker && typeof state.modelPicker.getSelected === 'function') {
725
+ state.config.modelKey = state.modelPicker.getSelected() || '';
726
+ }
727
+ await persistConfig();
728
+ notify('✓ Settings saved');
729
+ render();
730
+ } catch (error) {
731
+ notify('✗ Failed to save: ' + (error instanceof Error ? error.message : String(error)));
732
+ }
733
+ };
734
+
735
+ var pollNowBtn = UI.createButton({ text: 'Poll now', variant: 'outline' });
736
+ pollNowBtn.onclick = async function () { await pollOnce(); render(); };
737
+
738
+ var disconnectBtn = UI.createButton({ text: 'Disconnect', variant: 'ghost' });
739
+ disconnectBtn.onclick = async function () {
740
+ state.config.connected = false;
741
+ state.config.connectionStatus = 'disconnected';
742
+ state.config.sessionId = '';
743
+ state.config.qrCode = '';
744
+ await persistConfig({ connected: false, sessionId: '', connectionStatus: 'disconnected' });
745
+ stopAllBackgroundServices();
746
+ notify('Disconnected from WhatsApp');
747
+ render();
748
+ };
749
+
750
+ toolbar.appendChild(saveBtn);
751
+ toolbar.appendChild(pollNowBtn);
752
+ toolbar.appendChild(disconnectBtn);
753
+ }
754
+
755
+ setupCard.body.appendChild(toolbar);
756
+ root.appendChild(setupCard.root);
757
+
758
+ // Diagnostics
759
+ if (state.config.connected) {
760
+ var diagCard = UI.createCard();
761
+ diagCard.root.className += ' ce-card';
762
+ diagCard.body.innerHTML = '<h2 class="ce-section-title">Status & Diagnostics</h2>';
763
+ diagCard.body.innerHTML += '<div style="display:grid;gap:12px;margin-top:12px">' +
764
+ '<div><div class="ce-section-copy">Phone Number</div><div style="font-family:monospace;font-size:12px">' + escapeHtml(state.config.phoneNumber || 'unknown') + '</div></div>' +
765
+ '<div><div class="ce-section-copy">Last inbound</div><div style="font-family:monospace;font-size:12px">' + escapeHtml(state.config.lastInboundAt ? relTime(state.config.lastInboundAt) : 'never') + '</div></div>' +
766
+ '<div><div class="ce-section-copy">Last outbound</div><div style="font-family:monospace;font-size:12px">' + escapeHtml(state.config.lastOutboundAt ? relTime(state.config.lastOutboundAt) : 'never') + '</div></div>' +
767
+ '<div><div class="ce-section-copy">Session ID</div><div style="font-family:monospace;font-size:12px">' + escapeHtml(state.config.sessionId || 'none') + '</div></div>' +
768
+ '</div>';
769
+ root.appendChild(diagCard.root);
770
+
771
+ // Thread Mappings
772
+ var mappingsCard = UI.createCard();
773
+ mappingsCard.root.className += ' ce-card';
774
+ mappingsCard.body.innerHTML = '<h2 class="ce-section-title">Mapped chats</h2>';
775
+ var mappingList = UI.el('div', 'ce-list');
776
+ var mappingKeys = Object.keys(state.mappings || {});
777
+ if (!mappingKeys.length) {
778
+ mappingList.appendChild(UI.el('div', 'ce-empty', 'No mapped WhatsApp chats yet'));
779
+ } else {
780
+ mappingKeys.forEach(function (key) {
781
+ var mapping = asRecord(state.mappings[key]);
782
+ var row = UI.el('div', 'ce-list-row');
783
+ row.innerHTML =
784
+ '<div class="ce-list-row__main">' +
785
+ '<div class="ce-list-row__content">' +
786
+ '<div class="ce-list-row__title">' + escapeHtml(String(mapping.remoteChatName || mapping.remoteUserName || key)) + '</div>' +
787
+ '<div class="ce-list-row__meta">Chat ' + escapeHtml(String(mapping.remoteThreadId || '?')) + ' → ' + escapeHtml(String(mapping.chatonsConversationId || '?')) + '</div>' +
788
+ '<div class="ce-list-row__meta" style="margin-top:6px">Updated ' + escapeHtml(relTime(mapping.updatedAt || '')) + '</div>' +
789
+ '</div>' +
790
+ '</div>';
791
+ mappingList.appendChild(row);
792
+ });
793
+ }
794
+ mappingsCard.body.appendChild(mappingList);
795
+ root.appendChild(mappingsCard.root);
796
+
797
+ // Activity
798
+ var activityCard = UI.createCard();
799
+ activityCard.root.className += ' ce-card';
800
+ activityCard.body.innerHTML = '<h2 class="ce-section-title">Recent activity</h2>';
801
+ var activityList = UI.el('div', 'ce-list');
802
+ if (!state.updates.length) {
803
+ activityList.appendChild(UI.el('div', 'ce-empty', 'No activity yet'));
804
+ } else {
805
+ state.updates.forEach(function (entry) {
806
+ var row = UI.el('div', 'ce-list-row');
807
+ row.innerHTML =
808
+ '<div class="ce-list-row__main">' +
809
+ '<div class="ce-list-row__content">' +
810
+ '<div class="ce-list-row__title">' + escapeHtml(String(entry.direction || '?')) + ' • ' + escapeHtml(String(entry.user || '?')) + '</div>' +
811
+ '<div class="ce-list-row__meta">' + escapeHtml(relTime(entry.at || '')) + '</div>' +
812
+ '<div style="margin-top:8px;color:var(--ce-fg)">' + escapeHtml(String(entry.textPreview || '')) + '</div>' +
813
+ '</div>' +
814
+ '</div>';
815
+ activityList.appendChild(row);
816
+ });
817
+ }
818
+ activityCard.body.appendChild(activityList);
819
+ root.appendChild(activityCard.root);
820
+ }
821
+
822
+ app.appendChild(root);
823
+
824
+ // Initialize model picker
825
+ if (window.chatonUi && typeof window.chatonUi.createModelPicker === 'function') {
826
+ if (state.modelPicker && typeof state.modelPicker.destroy === 'function') state.modelPicker.destroy();
827
+ state.modelPicker = window.chatonUi.createModelPicker({
828
+ host: document.getElementById('modelPickerHost'),
829
+ onChange: function (modelKey) {
830
+ state.config.modelKey = modelKey || '';
831
+ persistConfig().catch(function (err) {
832
+ console.error('[whatsapp-ext] failed to persist model selection:', err);
833
+ });
834
+ },
835
+ labels: {
836
+ filterPlaceholder: 'Filter models...',
837
+ more: 'more',
838
+ scopedOnly: 'scoped only',
839
+ noScoped: 'No scoped models',
840
+ noModels: 'No models available'
841
+ }
842
+ });
843
+ window.chaton.listPiModels().then(function (res) {
844
+ if (!res || !res.ok || !state.modelPicker) return;
845
+ state.modelPicker.setModels(res.models || []);
846
+ state.modelPicker.setSelected(state.config.modelKey || null);
847
+ }).catch(function (err) {
848
+ console.error('[whatsapp-ext] failed to list models:', err);
849
+ });
850
+ }
851
+ }
852
+
853
+ // ============================================================================
854
+ // INITIALIZATION
855
+ // ============================================================================
856
+
857
+ async function init() {
858
+ if (!window.chaton) {
859
+ if (app) app.innerHTML = '<div style="padding:24px;font-family:system-ui">Chatons bridge unavailable</div>';
860
+ throw new Error('window.chaton is not available');
861
+ }
862
+
863
+ await loadPersistedState();
864
+
865
+ console.log('[whatsapp-ext] initialized state:', {
866
+ connected: state.config.connected,
867
+ phoneNumber: state.config.phoneNumber,
868
+ modelKey: state.config.modelKey,
869
+ outboundEnabled: state.config.outboundEnabled
870
+ });
871
+
872
+ state.uiRenderFn = function () { if (app) render(); };
873
+
874
+ if (app) render();
875
+
876
+ startOutboundLoop();
877
+ startPiMirrorListener();
878
+ startKeepalive();
879
+
880
+ if (state.config.connected) {
881
+ console.log('[whatsapp-ext] resuming polling (already connected)');
882
+ startPolling();
883
+ }
884
+
885
+ window.addEventListener('beforeunload', function () {
886
+ void persistConfig();
887
+ });
888
+
889
+ console.log('[whatsapp-ext] initialized. connected=' + state.config.connected);
890
+ }
891
+
892
+ void init();
893
+ })();
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@thibautrey/chatons-channel-whatsapp",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "WhatsApp channel bridge for Chatons. Connect via QR code, automatically syncs messages, and runs in the background using the shared Chatons UI component library.",
6
+ "main": "index.js",
7
+ "files": [
8
+ "index.js",
9
+ "index.html",
10
+ "icon.svg",
11
+ "chaton.extension.json",
12
+ "README.md"
13
+ ],
14
+ "keywords": [
15
+ "chatons",
16
+ "extension",
17
+ "channel",
18
+ "whatsapp",
19
+ "bridge",
20
+ "messaging",
21
+ "qr-code"
22
+ ],
23
+ "author": "Thibaut Rey",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/thibautrey/chatons-channel-whatsapp"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/thibautrey/chatons-channel-whatsapp/issues"
31
+ },
32
+ "homepage": "https://github.com/thibautrey/chatons-channel-whatsapp",
33
+ "chatonExtension": {
34
+ "kind": "channel",
35
+ "capabilities": [
36
+ "ui.mainView",
37
+ "queue.publish",
38
+ "queue.consume",
39
+ "storage.kv",
40
+ "host.notifications",
41
+ "host.conversations.read",
42
+ "host.conversations.write"
43
+ ]
44
+ }
45
+ }