@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 +121 -0
- package/chaton.extension.json +35 -0
- package/icon.svg +24 -0
- package/index.html +32 -0
- package/index.js +893 -0
- package/package.json +45 -0
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, '&')
|
|
65
|
+
.replace(/</g, '<')
|
|
66
|
+
.replace(/>/g, '>')
|
|
67
|
+
.replace(/"/g, '"')
|
|
68
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|