bloby-bot 0.21.13 → 0.22.1
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/bin/cli.js +2 -2
- package/cli/commands/start.ts +1 -1
- package/cli/commands/tunnel.ts +1 -1
- package/cli/core/config.ts +1 -1
- package/cli/core/server.ts +1 -1
- package/package.json +1 -1
- package/shared/config.ts +1 -1
- package/supervisor/agents/prompts/coder.txt +1 -1
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +2 -2
- package/supervisor/chat/src/lib/ws-client.ts +1 -1
- package/supervisor/index.ts +24 -0
- package/vite.config.ts +2 -2
- package/worker/prompts/bloby-system-prompt.txt +7 -7
- package/workspace/backend/index.ts +1 -1
- package/workspace/skills/chrome-extension/.claude-plugin/plugin.json +6 -0
- package/workspace/skills/chrome-extension/SKILL.md +133 -0
- package/workspace/skills/chrome-extension/background.js +239 -0
- package/workspace/skills/chrome-extension/content-script.js +218 -0
- package/workspace/skills/chrome-extension/content-style.css +86 -0
- package/workspace/skills/chrome-extension/icons/icon-128.png +0 -0
- package/workspace/skills/chrome-extension/icons/icon-16.png +0 -0
- package/workspace/skills/chrome-extension/icons/icon-48.png +0 -0
- package/workspace/skills/chrome-extension/manifest.json +46 -0
- package/workspace/skills/chrome-extension/panel/panel.html +74 -0
- package/workspace/skills/chrome-extension/popup/popup.css +124 -0
- package/workspace/skills/chrome-extension/popup/popup.html +47 -0
- package/workspace/skills/chrome-extension/popup/popup.js +115 -0
- package/workspace/skills/chrome-extension/skill.json +15 -0
package/bin/cli.js
CHANGED
|
@@ -431,7 +431,7 @@ async function runNamedTunnelSetup() {
|
|
|
431
431
|
|
|
432
432
|
// Generate cloudflared config
|
|
433
433
|
const config = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : {};
|
|
434
|
-
const port = config.port ||
|
|
434
|
+
const port = config.port || 7400;
|
|
435
435
|
const cfHome = path.join(os.homedir(), '.cloudflared');
|
|
436
436
|
const cfConfigPath = path.join(DATA_DIR, 'cloudflared-config.yml');
|
|
437
437
|
|
|
@@ -720,7 +720,7 @@ function createConfig() {
|
|
|
720
720
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
721
721
|
if (!fs.existsSync(CONFIG_PATH)) {
|
|
722
722
|
const config = {
|
|
723
|
-
port:
|
|
723
|
+
port: 7400,
|
|
724
724
|
username: '',
|
|
725
725
|
ai: { provider: '', model: '', apiKey: '' },
|
|
726
726
|
tunnel: { mode: 'quick' },
|
package/cli/commands/start.ts
CHANGED
|
@@ -64,7 +64,7 @@ export function registerStartCommand(program: Command) {
|
|
|
64
64
|
s.stop(pc.green('Server running'));
|
|
65
65
|
|
|
66
66
|
console.log(`\n${pc.bold('Bloby is ready!')}`);
|
|
67
|
-
console.log(` ${pc.dim('Local:')} ${pc.blue(`http://localhost:${config.port ||
|
|
67
|
+
console.log(` ${pc.dim('Local:')} ${pc.blue(`http://localhost:${config.port || 7400}`)}`);
|
|
68
68
|
|
|
69
69
|
if (result.tunnelUrl && hasTunnel) {
|
|
70
70
|
console.log(` ${pc.dim('Tunnel:')} ${pc.blue(result.tunnelUrl)}`);
|
package/cli/commands/tunnel.ts
CHANGED
|
@@ -54,7 +54,7 @@ async function runNamedTunnelSetup() {
|
|
|
54
54
|
const domain = await text({ message: 'Your domain (e.g. bot.mydomain.com):' });
|
|
55
55
|
if (isCancel(domain) || !domain) { cancel('Domain is required.'); process.exit(1); }
|
|
56
56
|
|
|
57
|
-
const { port =
|
|
57
|
+
const { port = 7400 } = safeLoadConfig();
|
|
58
58
|
const cfHome = path.join(os.homedir(), '.cloudflared');
|
|
59
59
|
const cfConfigPath = path.join(DATA_DIR, 'cloudflared-config.yml');
|
|
60
60
|
|
package/cli/core/config.ts
CHANGED
package/cli/core/server.ts
CHANGED
|
@@ -49,7 +49,7 @@ export function bootServer({
|
|
|
49
49
|
|
|
50
50
|
resolve({
|
|
51
51
|
child,
|
|
52
|
-
tunnelUrl: tunnelUrl || `http://localhost:${config.port ||
|
|
52
|
+
tunnelUrl: tunnelUrl || `http://localhost:${config.port || 7400}`,
|
|
53
53
|
relayUrl: relayUrl || config.relay?.url || null,
|
|
54
54
|
tunnelFailed,
|
|
55
55
|
viteWarm,
|
package/package.json
CHANGED
package/shared/config.ts
CHANGED
|
@@ -68,7 +68,7 @@ Your working directory is the `workspace/` folder. This is your full-stack works
|
|
|
68
68
|
|
|
69
69
|
## Backend Routing (Critical)
|
|
70
70
|
|
|
71
|
-
A supervisor process sits in front of everything on port
|
|
71
|
+
A supervisor process sits in front of everything on port 7400. It strips the `/app` prefix before forwarding to the backend, preserving the `/api/` path.
|
|
72
72
|
|
|
73
73
|
```
|
|
74
74
|
Browser: GET /app/api/tasks → Supervisor strips /app → Backend receives: GET /api/tasks
|
|
@@ -29,8 +29,8 @@ function formatTime(iso: string): string {
|
|
|
29
29
|
/** Convert backtick-wrapped WhatsApp QR URL into a markdown link */
|
|
30
30
|
function preprocessContent(text: string): string {
|
|
31
31
|
return text.replace(
|
|
32
|
-
/`http:\/\/localhost
|
|
33
|
-
'[pair-whatsapp](
|
|
32
|
+
/`http:\/\/localhost:\d+\/api\/channels\/whatsapp\/qr-page`/g,
|
|
33
|
+
'[pair-whatsapp](/api/channels/whatsapp/qr-page)'
|
|
34
34
|
);
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -21,7 +21,7 @@ export class WsClient {
|
|
|
21
21
|
|
|
22
22
|
constructor(url?: string, tokenGetter?: (() => string | null) | null) {
|
|
23
23
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
24
|
-
const host = import.meta.env.DEV ? 'localhost:
|
|
24
|
+
const host = import.meta.env.DEV ? 'localhost:7400' : location.host;
|
|
25
25
|
this.url = url ?? `${proto}//${host}/ws`;
|
|
26
26
|
this.tokenGetter = tokenGetter ?? null;
|
|
27
27
|
}
|
package/supervisor/index.ts
CHANGED
|
@@ -215,11 +215,35 @@ const RECOVERING_HTML = `<!DOCTYPE html><html style="background:#222122"><head><
|
|
|
215
215
|
</div><script>setTimeout(function(){location.reload()},3000)</script>
|
|
216
216
|
<script src="/bloby/widget.js"></script></body></html>`;
|
|
217
217
|
|
|
218
|
+
/** Kill any stale process holding a port. Ensures clean startup after crashes/updates. */
|
|
219
|
+
function killPort(port: number): void {
|
|
220
|
+
try {
|
|
221
|
+
const pids = execSync(`lsof -ti :${port} 2>/dev/null`, { encoding: 'utf-8' }).trim();
|
|
222
|
+
if (pids) {
|
|
223
|
+
const pidList = pids.split('\n').filter(Boolean);
|
|
224
|
+
const ownPid = process.pid.toString();
|
|
225
|
+
const toKill = pidList.filter((p) => p !== ownPid);
|
|
226
|
+
if (toKill.length) {
|
|
227
|
+
log.info(`[startup] Killing stale process(es) on port ${port}: ${toKill.join(', ')}`);
|
|
228
|
+
execSync(`kill -9 ${toKill.join(' ')} 2>/dev/null`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
// No process on port, or kill failed (already dead) — fine
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
218
236
|
export async function startSupervisor() {
|
|
219
237
|
const config = loadConfig();
|
|
220
238
|
const backendPort = getBackendPort(config.port);
|
|
221
239
|
const internalSecret = crypto.randomBytes(16).toString('hex');
|
|
222
240
|
|
|
241
|
+
// Kill any stale processes from previous crashes/updates
|
|
242
|
+
log.info(`[startup] Clearing ports ${config.port}, ${config.port + 2}, ${backendPort}...`);
|
|
243
|
+
killPort(config.port); // supervisor
|
|
244
|
+
killPort(config.port + 2); // vite
|
|
245
|
+
killPort(backendPort); // backend
|
|
246
|
+
|
|
223
247
|
// Create HTTP server first (Vite needs it for HMR WebSocket)
|
|
224
248
|
// The request handler is set up later via server.on('request')
|
|
225
249
|
const server = http.createServer();
|
package/vite.config.ts
CHANGED
|
@@ -24,10 +24,10 @@ export default defineConfig({
|
|
|
24
24
|
port: 5173,
|
|
25
25
|
proxy: {
|
|
26
26
|
'/app/api': {
|
|
27
|
-
target: 'http://localhost:
|
|
27
|
+
target: 'http://localhost:7404',
|
|
28
28
|
rewrite: (path) => path.replace(/^\/app/, ''),
|
|
29
29
|
},
|
|
30
|
-
'/api': 'http://localhost:
|
|
30
|
+
'/api': 'http://localhost:7400',
|
|
31
31
|
},
|
|
32
32
|
warmup: {
|
|
33
33
|
clientFiles: ['./src/main.tsx'],
|
|
@@ -278,7 +278,7 @@ skills/
|
|
|
278
278
|
|
|
279
279
|
Only ONE skill can be active for customer-facing mode at a time. The active skill is set in the channel config (`channels.whatsapp.skill`). When your human asks to switch skills, update the config:
|
|
280
280
|
```bash
|
|
281
|
-
curl -s -X POST http://localhost:
|
|
281
|
+
curl -s -X POST http://localhost:7400/api/channels/whatsapp/configure \
|
|
282
282
|
-H "Content-Type: application/json" -d '{"skill":"whatsapp-clinic"}'
|
|
283
283
|
```
|
|
284
284
|
|
|
@@ -357,14 +357,14 @@ The format is: `[Channel | phone | role | name (optional)]`
|
|
|
357
357
|
### Setting Up WhatsApp
|
|
358
358
|
|
|
359
359
|
When your human asks to configure WhatsApp:
|
|
360
|
-
1. Start the connection: `curl -s -X POST http://localhost:
|
|
361
|
-
2. Tell them to open the QR page: `http://localhost:
|
|
360
|
+
1. Start the connection: `curl -s -X POST http://localhost:7400/api/channels/whatsapp/connect`
|
|
361
|
+
2. Tell them to open the QR page: `http://localhost:7400/api/channels/whatsapp/qr-page` (Don't mention the URL until you are actually starting the connection)
|
|
362
362
|
3. They scan the QR with their WhatsApp app
|
|
363
363
|
4. The default mode is **channel** (self-chat only)
|
|
364
364
|
|
|
365
365
|
To switch to **business mode** with admin numbers:
|
|
366
366
|
```bash
|
|
367
|
-
curl -s -X POST http://localhost:
|
|
367
|
+
curl -s -X POST http://localhost:7400/api/channels/whatsapp/configure \
|
|
368
368
|
-H "Content-Type: application/json" \
|
|
369
369
|
-d '{"mode":"business","admins":["+17865551234","+5511999887766"]}'
|
|
370
370
|
```
|
|
@@ -373,7 +373,7 @@ curl -s -X POST http://localhost:3000/api/channels/whatsapp/configure \
|
|
|
373
373
|
|
|
374
374
|
To INITIATE a WhatsApp message (during pulse, cron, or when you want to reach out first):
|
|
375
375
|
```bash
|
|
376
|
-
curl -s -X POST http://localhost:
|
|
376
|
+
curl -s -X POST http://localhost:7400/api/channels/send \
|
|
377
377
|
-H "Content-Type: application/json" \
|
|
378
378
|
-d '{"channel":"whatsapp","to":"5511999888777","text":"Your appointment is confirmed for tomorrow at 2pm."}'
|
|
379
379
|
```
|
|
@@ -403,7 +403,7 @@ This is your memory of that customer. Next time they message, read their file fi
|
|
|
403
403
|
| `/api/channels/whatsapp/configure` | POST | Set mode + admins array |
|
|
404
404
|
| `/api/channels/send` | POST | Send proactive message via any channel |
|
|
405
405
|
|
|
406
|
-
All endpoints are on `http://localhost:
|
|
406
|
+
All endpoints are on `http://localhost:7400`.
|
|
407
407
|
|
|
408
408
|
---
|
|
409
409
|
|
|
@@ -480,7 +480,7 @@ Rules:
|
|
|
480
480
|
|
|
481
481
|
## Backend Routing (Critical)
|
|
482
482
|
|
|
483
|
-
A supervisor process sits in front of everything on port
|
|
483
|
+
A supervisor process sits in front of everything on port 7400. It strips the `/app` prefix before forwarding to the backend, preserving the `/api/` path.
|
|
484
484
|
|
|
485
485
|
```
|
|
486
486
|
Browser: GET /app/api/tasks → Supervisor strips /app → Backend receives: GET /api/tasks
|
|
@@ -3,7 +3,7 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import Database from 'better-sqlite3';
|
|
5
5
|
|
|
6
|
-
const PORT = parseInt(process.env.BACKEND_PORT || '
|
|
6
|
+
const PORT = parseInt(process.env.BACKEND_PORT || '7404', 10);
|
|
7
7
|
const WORKSPACE = path.resolve(import.meta.dirname, '..');
|
|
8
8
|
|
|
9
9
|
// Load workspace/.env manually (no dotenv dep needed)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Chrome Extension
|
|
2
|
+
|
|
3
|
+
## What This Is
|
|
4
|
+
|
|
5
|
+
A Chrome extension that puts you (the bloby) on every webpage your human visits. A floating bubble appears in the bottom-right corner of every tab. Your human clicks it, and a chat panel slides out — same chat, same conversation, fully mirrored with the dashboard and WhatsApp.
|
|
6
|
+
|
|
7
|
+
The extension also gives your human the ability to share page context with you — URL, title, selected text, and structured data (product info, prices). This lets you help with what they're actively looking at.
|
|
8
|
+
|
|
9
|
+
## Dependencies
|
|
10
|
+
|
|
11
|
+
None.
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
### Step 1: Generate a pairing code
|
|
16
|
+
|
|
17
|
+
When your human asks to set up the Chrome extension, generate a pairing code:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
curl -s -X POST https://api.bloby.bot/api/extension/pair/create \
|
|
21
|
+
-H "Content-Type: application/json" \
|
|
22
|
+
-H "Authorization: Bearer $(cat ~/.bloby/config.json | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4)"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This returns a 6-digit code that expires in 5 minutes.
|
|
26
|
+
|
|
27
|
+
### Step 2: Walk your human through installation
|
|
28
|
+
|
|
29
|
+
Tell your human:
|
|
30
|
+
|
|
31
|
+
1. Open `chrome://extensions` in Chrome
|
|
32
|
+
2. Turn on **Developer Mode** (toggle in the top-right corner)
|
|
33
|
+
3. Click **Load unpacked**
|
|
34
|
+
4. Select the folder: `~/.bloby/workspace/skills/chrome-extension/`
|
|
35
|
+
5. Click the puzzle piece icon in Chrome's toolbar and pin Bloby
|
|
36
|
+
6. Click the Bloby icon and enter the 6-digit pairing code
|
|
37
|
+
|
|
38
|
+
### Step 3: Verify it works
|
|
39
|
+
|
|
40
|
+
After your human enters the code, the extension connects to your server. A gradient bubble should appear on every webpage. Ask your human to confirm they see it.
|
|
41
|
+
|
|
42
|
+
If the code expired, generate a new one (repeat Step 1).
|
|
43
|
+
|
|
44
|
+
### Re-pairing
|
|
45
|
+
|
|
46
|
+
If your human needs to reconnect (new device, token expired):
|
|
47
|
+
1. Generate a new pairing code (Step 1)
|
|
48
|
+
2. They click the Bloby icon in Chrome toolbar → enter the new code
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
### How messages from the extension arrive
|
|
53
|
+
|
|
54
|
+
Messages from the extension come through the same WebSocket as dashboard chat messages. There is no difference — the extension is just another connected client. Your responses appear in both the extension and the dashboard simultaneously.
|
|
55
|
+
|
|
56
|
+
### Page context
|
|
57
|
+
|
|
58
|
+
When your human sends a message from the extension, the message may include page context. This appears as metadata at the start of the message:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
[Page: https://amazon.com/dp/B0XXXXX | iPhone 16 Pro - 256GB | $999.00]
|
|
62
|
+
Can you find this cheaper?
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The context includes:
|
|
66
|
+
- **URL** — the page they're on
|
|
67
|
+
- **Title** — the page title
|
|
68
|
+
- **Selection** — any text they've highlighted (up to 2000 chars)
|
|
69
|
+
- **Product info** — name, price, currency (extracted from JSON-LD structured data if available)
|
|
70
|
+
- **Meta description** — page summary
|
|
71
|
+
|
|
72
|
+
You can use this context directly, or fetch the URL yourself with curl/fetch tools for more detail.
|
|
73
|
+
|
|
74
|
+
### Common use cases
|
|
75
|
+
|
|
76
|
+
**Price comparison:**
|
|
77
|
+
Human is on a product page → asks "find this cheaper" → you have the URL, product name, and price → search the web for alternatives.
|
|
78
|
+
|
|
79
|
+
**Page summarization:**
|
|
80
|
+
Human is on a long article → asks "summarize this" → you fetch the URL and produce a summary.
|
|
81
|
+
|
|
82
|
+
**Video transcription:**
|
|
83
|
+
Human is on YouTube → asks "transcribe this" → you extract the video URL, use available tools to get the transcript.
|
|
84
|
+
|
|
85
|
+
**Save to workspace:**
|
|
86
|
+
Human finds something interesting → asks "save this to my research notes" → you write to a file in the workspace.
|
|
87
|
+
|
|
88
|
+
**Shopping assistant:**
|
|
89
|
+
Human is browsing → asks "add this to my wishlist" → you maintain a wishlist file in the workspace.
|
|
90
|
+
|
|
91
|
+
**Research assistant:**
|
|
92
|
+
Human is reading documentation → highlights text → asks "explain this" → you get the selected text and explain it in context.
|
|
93
|
+
|
|
94
|
+
### Working with the page URL
|
|
95
|
+
|
|
96
|
+
You have full tool access. If the context isn't enough, fetch the page yourself:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
curl -s "https://the-url-from-context" | head -200
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Or use an MCP fetch tool if configured.
|
|
103
|
+
|
|
104
|
+
## Human Interaction
|
|
105
|
+
|
|
106
|
+
### What to tell your human
|
|
107
|
+
|
|
108
|
+
When they ask about the extension:
|
|
109
|
+
- "I can see whatever page you're on and help you with it"
|
|
110
|
+
- "Just click my bubble on any page and ask me anything"
|
|
111
|
+
- "If you highlight text on a page, I can see what you selected"
|
|
112
|
+
- "I can find better prices, summarize articles, save things to your workspace — whatever you need"
|
|
113
|
+
|
|
114
|
+
### What NOT to say
|
|
115
|
+
|
|
116
|
+
Never mention "extension", "content script", "background worker", or any technical implementation details. From your human's perspective, you're just... everywhere. Available on every page. That's it.
|
|
117
|
+
|
|
118
|
+
### Permissions
|
|
119
|
+
|
|
120
|
+
The extension only reads page content when your human actively opens the chat panel. It does NOT:
|
|
121
|
+
- Track browsing history
|
|
122
|
+
- Read pages in the background
|
|
123
|
+
- Send data anywhere except to your server
|
|
124
|
+
- Access passwords, form data, or cookies
|
|
125
|
+
|
|
126
|
+
If your human asks about privacy, be transparent about this.
|
|
127
|
+
|
|
128
|
+
## Notes
|
|
129
|
+
|
|
130
|
+
- The extension connects via the relay URL (e.g., `bloby.bloby.bot`). If the relay is down or the tunnel is offline, the extension chat won't connect. The bubble still appears but messages won't send.
|
|
131
|
+
- The pairing code is single-use and expires in 5 minutes. Generate a new one if it expires.
|
|
132
|
+
- The extension works on all pages except Chrome internal pages (`chrome://`, `chrome-extension://`) and your own Bloby dashboard domain (to avoid double-bubble).
|
|
133
|
+
- Multiple browsers/devices can be paired — each gets its own pairing code. They all share the same conversation.
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bloby Chrome Extension — Background Service Worker
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Manages the WebSocket connection to the user's Bloby server
|
|
6
|
+
* - Bridges messages between content scripts and the server
|
|
7
|
+
* - Handles pairing state (serverUrl stored in chrome.storage.local)
|
|
8
|
+
* - Keeps WS alive with heartbeat pings
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
let ws = null;
|
|
12
|
+
let config = null; // { serverUrl, username, tier }
|
|
13
|
+
let reconnectTimer = null;
|
|
14
|
+
let reconnectDelay = 1000;
|
|
15
|
+
const MAX_RECONNECT_DELAY = 8000;
|
|
16
|
+
const HEARTBEAT_MS = 25000;
|
|
17
|
+
let heartbeatTimer = null;
|
|
18
|
+
|
|
19
|
+
// ── Config Management ──────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
async function loadConfig() {
|
|
22
|
+
const data = await chrome.storage.local.get(['serverUrl', 'username', 'tier', 'authToken']);
|
|
23
|
+
if (data.serverUrl) {
|
|
24
|
+
config = data;
|
|
25
|
+
console.log(`[bloby-bg] Config loaded: ${config.serverUrl} (${config.username})`);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
console.log('[bloby-bg] No config found — not paired');
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function saveConfig(newConfig) {
|
|
33
|
+
config = newConfig;
|
|
34
|
+
await chrome.storage.local.set(newConfig);
|
|
35
|
+
console.log(`[bloby-bg] Config saved: ${config.serverUrl}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function clearConfig() {
|
|
39
|
+
config = null;
|
|
40
|
+
await chrome.storage.local.clear();
|
|
41
|
+
disconnectWs();
|
|
42
|
+
console.log('[bloby-bg] Config cleared — unpaired');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── WebSocket Connection ───────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function connectWs() {
|
|
48
|
+
if (!config?.serverUrl) return;
|
|
49
|
+
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
|
50
|
+
|
|
51
|
+
const proto = config.serverUrl.startsWith('https') ? 'wss' : 'ws';
|
|
52
|
+
const host = config.serverUrl.replace(/^https?:\/\//, '');
|
|
53
|
+
let url = `${proto}://${host}/bloby/ws`;
|
|
54
|
+
if (config.authToken) {
|
|
55
|
+
url += `?token=${config.authToken}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(`[bloby-bg] Connecting WS: ${url.replace(/token=.*/, 'token=***')}`);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
ws = new WebSocket(url);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error('[bloby-bg] WS constructor error:', err);
|
|
64
|
+
scheduleReconnect();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ws.onopen = () => {
|
|
69
|
+
console.log('[bloby-bg] WS connected');
|
|
70
|
+
reconnectDelay = 1000;
|
|
71
|
+
startHeartbeat();
|
|
72
|
+
// Notify all tabs
|
|
73
|
+
broadcastToTabs({ type: 'bloby:ws-connected' });
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
ws.onmessage = (event) => {
|
|
77
|
+
try {
|
|
78
|
+
const msg = JSON.parse(event.data);
|
|
79
|
+
// Forward all server messages to content scripts
|
|
80
|
+
broadcastToTabs(msg);
|
|
81
|
+
} catch { /* ignore non-JSON */ }
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
ws.onclose = (event) => {
|
|
85
|
+
console.log(`[bloby-bg] WS closed: code=${event.code} reason=${event.reason}`);
|
|
86
|
+
stopHeartbeat();
|
|
87
|
+
ws = null;
|
|
88
|
+
broadcastToTabs({ type: 'bloby:ws-disconnected' });
|
|
89
|
+
scheduleReconnect();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
ws.onerror = (err) => {
|
|
93
|
+
console.error('[bloby-bg] WS error');
|
|
94
|
+
// onclose will fire after this
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function disconnectWs() {
|
|
99
|
+
stopHeartbeat();
|
|
100
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
101
|
+
if (ws) {
|
|
102
|
+
ws.onclose = null; // prevent reconnect
|
|
103
|
+
ws.close();
|
|
104
|
+
ws = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function scheduleReconnect() {
|
|
109
|
+
if (reconnectTimer) return;
|
|
110
|
+
console.log(`[bloby-bg] Reconnecting in ${reconnectDelay}ms...`);
|
|
111
|
+
reconnectTimer = setTimeout(() => {
|
|
112
|
+
reconnectTimer = null;
|
|
113
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
114
|
+
connectWs();
|
|
115
|
+
}, reconnectDelay);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function startHeartbeat() {
|
|
119
|
+
stopHeartbeat();
|
|
120
|
+
heartbeatTimer = setInterval(() => {
|
|
121
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
122
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
123
|
+
}
|
|
124
|
+
}, HEARTBEAT_MS);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function stopHeartbeat() {
|
|
128
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Send to Server ─────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function sendToServer(msg) {
|
|
134
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
135
|
+
ws.send(JSON.stringify(msg));
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
console.warn('[bloby-bg] WS not connected — message dropped');
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Broadcast to All Tabs ──────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
async function broadcastToTabs(msg) {
|
|
145
|
+
try {
|
|
146
|
+
const tabs = await chrome.tabs.query({});
|
|
147
|
+
for (const tab of tabs) {
|
|
148
|
+
if (tab.id) {
|
|
149
|
+
chrome.tabs.sendMessage(tab.id, msg).catch(() => {
|
|
150
|
+
// Tab doesn't have content script — ignore
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch { /* ignore */ }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Message Handler (from content scripts + popup) ─────────────────────────
|
|
158
|
+
|
|
159
|
+
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
160
|
+
// Popup: get current state
|
|
161
|
+
if (msg.type === 'bloby:get-state') {
|
|
162
|
+
sendResponse({
|
|
163
|
+
paired: !!config?.serverUrl,
|
|
164
|
+
connected: ws?.readyState === WebSocket.OPEN,
|
|
165
|
+
config: config ? { serverUrl: config.serverUrl, username: config.username } : null,
|
|
166
|
+
});
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Popup: pair with code
|
|
171
|
+
if (msg.type === 'bloby:pair') {
|
|
172
|
+
handlePair(msg.code).then(sendResponse);
|
|
173
|
+
return true; // async response
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Popup: unpair
|
|
177
|
+
if (msg.type === 'bloby:unpair') {
|
|
178
|
+
clearConfig().then(() => sendResponse({ success: true }));
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Content script: send message to server
|
|
183
|
+
if (msg.type === 'bloby:send') {
|
|
184
|
+
const ok = sendToServer(msg.payload);
|
|
185
|
+
sendResponse({ sent: ok });
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Content script: get page context
|
|
190
|
+
if (msg.type === 'bloby:page-context') {
|
|
191
|
+
// Content script provides context, we just forward
|
|
192
|
+
sendResponse({ received: true });
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── Pairing Flow ───────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
async function handlePair(code) {
|
|
200
|
+
try {
|
|
201
|
+
console.log(`[bloby-bg] Verifying pairing code: ${code}`);
|
|
202
|
+
|
|
203
|
+
const res = await fetch('https://api.bloby.bot/api/extension/pair/verify', {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: { 'Content-Type': 'application/json' },
|
|
206
|
+
body: JSON.stringify({ code }),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
const data = await res.json().catch(() => ({}));
|
|
211
|
+
console.log(`[bloby-bg] Pairing failed: ${data.error || res.status}`);
|
|
212
|
+
return { success: false, error: data.error || 'Invalid code' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const data = await res.json();
|
|
216
|
+
console.log(`[bloby-bg] Pairing verified: ${data.serverUrl}`);
|
|
217
|
+
|
|
218
|
+
await saveConfig({
|
|
219
|
+
serverUrl: data.serverUrl,
|
|
220
|
+
username: data.username,
|
|
221
|
+
tier: data.tier,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Connect immediately
|
|
225
|
+
connectWs();
|
|
226
|
+
|
|
227
|
+
return { success: true, username: data.username, serverUrl: data.serverUrl };
|
|
228
|
+
} catch (err) {
|
|
229
|
+
console.error('[bloby-bg] Pairing error:', err);
|
|
230
|
+
return { success: false, error: 'Network error — check your connection' };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Startup ────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
(async () => {
|
|
237
|
+
const hasCfg = await loadConfig();
|
|
238
|
+
if (hasCfg) connectWs();
|
|
239
|
+
})();
|