bloby-bot 0.66.1 → 0.68.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 +11 -5
- package/bin/cli.js +1944 -1463
- package/package.json +4 -5
- package/scripts/install.sh +1 -0
- package/supervisor/backend.ts +11 -0
- package/supervisor/channels/manager.ts +2 -0
- package/supervisor/channels/whatsapp-auth.ts +216 -0
- package/supervisor/channels/whatsapp.ts +106 -11
- package/supervisor/index.ts +91 -2
- package/tsconfig.json +1 -1
- package/cli/commands/daemon.ts +0 -31
- package/cli/commands/init.ts +0 -40
- package/cli/commands/start.ts +0 -91
- package/cli/commands/tunnel.ts +0 -175
- package/cli/commands/update.ts +0 -174
- package/cli/core/base-adapter.ts +0 -99
- package/cli/core/cloudflared.ts +0 -71
- package/cli/core/config.ts +0 -58
- package/cli/core/os-detector.ts +0 -31
- package/cli/core/server.ts +0 -87
- package/cli/core/types.ts +0 -15
- package/cli/index.ts +0 -72
- package/cli/platforms/darwin.ts +0 -110
- package/cli/platforms/index.ts +0 -20
- package/cli/platforms/linux.ts +0 -116
- package/cli/platforms/win32.ts +0 -20
- package/cli/utils/ui.ts +0 -38
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bloby-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.0",
|
|
4
4
|
"releaseNotes": [
|
|
5
|
-
"1. Fix:
|
|
6
|
-
"2.
|
|
7
|
-
"3.
|
|
5
|
+
"1. Fix: agent self-update now actively relaunches the daemon (launchctl/systemctl) instead of relying on launchd KeepAlive — so `update` from chat/pulse comes back up reliably, matching manual `bloby update`",
|
|
6
|
+
"2. New agent control surface (/__bloby/control/*): restart-and-verify the backend in-turn, tail backend + frontend logs, and acked self-update — replacing the flaky touch .update/.restart triggers",
|
|
7
|
+
"3. Frontend errors (runtime, console, Vite) are now captured so the \"Copy error for your agent\" button is never empty"
|
|
8
8
|
],
|
|
9
9
|
"description": "Self-hosted, self-evolving AI agent with its own dashboard.",
|
|
10
10
|
"type": "module",
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"LICENSE",
|
|
17
17
|
"bin/",
|
|
18
|
-
"cli/",
|
|
19
18
|
"dist-bloby/",
|
|
20
19
|
"supervisor/",
|
|
21
20
|
"worker/",
|
package/scripts/install.sh
CHANGED
|
@@ -329,6 +329,7 @@ printf "\n"
|
|
|
329
329
|
printf " ${BLUE}bloby init${RESET} Set up your bot\n"
|
|
330
330
|
printf " ${BLUE}bloby start${RESET} Start your bot\n"
|
|
331
331
|
printf " ${BLUE}bloby status${RESET} Check if it's running\n"
|
|
332
|
+
printf " ${BLUE}bloby help${RESET} All commands\n"
|
|
332
333
|
printf "\n"
|
|
333
334
|
printf " ${PINK}>${RESET} Run ${BLUE}bloby init${RESET} to begin.\n"
|
|
334
335
|
printf " ${DIM}(Open a new terminal if 'bloby' isn't found yet)${RESET}\n"
|
package/supervisor/backend.ts
CHANGED
|
@@ -9,6 +9,17 @@ let child: ChildProcess | null = null;
|
|
|
9
9
|
let restarts = 0;
|
|
10
10
|
let lastSpawnTime = 0;
|
|
11
11
|
let intentionallyStopped = false;
|
|
12
|
+
|
|
13
|
+
// Hard backstop against an orphaned backend. SIGTERM/SIGINT go through stopBackend() for a graceful
|
|
14
|
+
// stop, but process.exit() (server EADDRINUSE handler, self-update relaunch, fatal errors) bypasses
|
|
15
|
+
// those handlers AND can't run async cleanup — so the backend child would survive as an orphan
|
|
16
|
+
// (PPID→1) still holding BACKEND_PORT, EADDRINUSE-ing every later backend spawn until killPort
|
|
17
|
+
// happens to reclaim it. 'exit' fires on EVERY exit path (including process.exit) and allows the one
|
|
18
|
+
// synchronous kill we need. (A SIGKILL of the supervisor itself can't be caught — killPort covers it
|
|
19
|
+
// on the next startup.)
|
|
20
|
+
process.on('exit', () => {
|
|
21
|
+
try { if (child && child.exitCode === null) child.kill('SIGKILL'); } catch {}
|
|
22
|
+
});
|
|
12
23
|
// True once the backend has crash-looped past MAX_RESTARTS and given up — i.e. it's down and
|
|
13
24
|
// will NOT come back without the user fixing the code. The supervisor shows the "backend down"
|
|
14
25
|
// interstitial in this state. Cleared on every spawn attempt (a deliberate restart is "trying again").
|
|
@@ -221,6 +221,8 @@ export class ChannelManager {
|
|
|
221
221
|
/** Start WhatsApp connection (triggers QR flow if no credentials) */
|
|
222
222
|
async connectWhatsApp(): Promise<void> {
|
|
223
223
|
let provider = this.providers.get('whatsapp');
|
|
224
|
+
// Already linked and online — don't tear down a live socket to start a new one
|
|
225
|
+
if (provider?.getStatus().connected) return;
|
|
224
226
|
if (!provider) {
|
|
225
227
|
const whatsapp = new WhatsAppChannel(
|
|
226
228
|
(sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images, inboundKey) => {
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic multi-file auth state for Baileys.
|
|
3
|
+
*
|
|
4
|
+
* Replaces Baileys' useMultiFileAuthState, whose writeData is a bare fs.writeFile
|
|
5
|
+
* (truncate-then-write, no rename). A process death between the truncate and the
|
|
6
|
+
* write leaves a 0-byte creds.json; on the next boot Baileys' readData swallows the
|
|
7
|
+
* JSON.parse error and silently mints a FRESH identity — the session looks "logged
|
|
8
|
+
* out" and the user is forced to re-scan the QR even though WhatsApp never unlinked
|
|
9
|
+
* the device. creds.update fires constantly while connected (key rotations,
|
|
10
|
+
* app-state sync), so that corruption window recurs for the whole session.
|
|
11
|
+
*
|
|
12
|
+
* Hardening over upstream:
|
|
13
|
+
* - Atomic writes: every file is written to a temp name then rename()d into place,
|
|
14
|
+
* so a kill at any moment leaves the old or the new content — never a truncated file.
|
|
15
|
+
* - creds.json is mirrored to creds.json.bak on every save; on load, a missing or
|
|
16
|
+
* corrupt creds.json is restored from the backup instead of re-initializing.
|
|
17
|
+
* - A corrupt creds.json with no usable backup is quarantined (renamed) so
|
|
18
|
+
* hasCredentials() stops treating the dead session as linked.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { BufferJSON, initAuthCreds, proto } from '@whiskeysockets/baileys';
|
|
22
|
+
import type { AuthenticationCreds, AuthenticationState, SignalDataTypeMap } from '@whiskeysockets/baileys';
|
|
23
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'fs/promises';
|
|
24
|
+
import fs from 'fs';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import { log } from '../../shared/logger.js';
|
|
27
|
+
|
|
28
|
+
/** Serializes reads/writes per file path (Baileys events fire concurrently). In-process
|
|
29
|
+
* only — cross-process safety comes from the atomic rename, not from this. */
|
|
30
|
+
const fileQueues = new Map<string, Promise<unknown>>();
|
|
31
|
+
|
|
32
|
+
function queued<T>(file: string, task: () => Promise<T>): Promise<T> {
|
|
33
|
+
const prev = fileQueues.get(file) || Promise.resolve();
|
|
34
|
+
const next = prev.then(task, task);
|
|
35
|
+
// Store a value-free tail (a resolved read would otherwise pin the file's contents
|
|
36
|
+
// in memory forever) and drop the entry once its queue drains.
|
|
37
|
+
const tail = next.then(() => undefined, () => undefined);
|
|
38
|
+
fileQueues.set(file, tail);
|
|
39
|
+
void tail.then(() => {
|
|
40
|
+
if (fileQueues.get(file) === tail) fileQueues.delete(file);
|
|
41
|
+
});
|
|
42
|
+
return next;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Await everything currently queued for an auth folder — drained on disconnect so a
|
|
46
|
+
* process exit right after can't clip a signal-key or creds write. */
|
|
47
|
+
export function flushAuthWrites(folder: string): Promise<void> {
|
|
48
|
+
const prefix = folder.endsWith(path.sep) ? folder : folder + path.sep;
|
|
49
|
+
const tails: Promise<unknown>[] = [];
|
|
50
|
+
for (const [file, tail] of fileQueues) {
|
|
51
|
+
if (file.startsWith(prefix)) tails.push(tail);
|
|
52
|
+
}
|
|
53
|
+
return Promise.all(tails).then(() => undefined);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Same name mangling as upstream so existing auth dirs keep working as-is. */
|
|
57
|
+
const fixFileName = (file: string) => file.replace(/\//g, '__').replace(/:/g, '-');
|
|
58
|
+
|
|
59
|
+
/** True when the parsed object looks like real Baileys credentials (not null/empty/garbage). */
|
|
60
|
+
function isValidCreds(creds: unknown): creds is AuthenticationCreds {
|
|
61
|
+
const c = creds as AuthenticationCreds | null;
|
|
62
|
+
return !!(
|
|
63
|
+
c && typeof c === 'object' &&
|
|
64
|
+
c.noiseKey && c.signedIdentityKey && c.signedPreKey &&
|
|
65
|
+
typeof c.registrationId === 'number'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function writeAtomic(filePath: string, data: string): Promise<void> {
|
|
70
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(tmp, data);
|
|
73
|
+
await rename(tmp, filePath);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
try { await unlink(tmp); } catch {}
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface AtomicAuthState {
|
|
81
|
+
state: AuthenticationState;
|
|
82
|
+
saveCreds: () => Promise<void>;
|
|
83
|
+
/** creds.json was missing/corrupt but recovered from creds.json.bak */
|
|
84
|
+
restoredFromBackup: boolean;
|
|
85
|
+
/** No usable credentials found — a brand-new identity was initialized (pairing needed) */
|
|
86
|
+
freshIdentity: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function useAtomicMultiFileAuthState(folder: string): Promise<AtomicAuthState> {
|
|
90
|
+
await mkdir(folder, { recursive: true });
|
|
91
|
+
|
|
92
|
+
const filePath = (file: string) => path.join(folder, fixFileName(file));
|
|
93
|
+
|
|
94
|
+
// Sweep temp files orphaned by a previous process death mid-write
|
|
95
|
+
try {
|
|
96
|
+
for (const f of fs.readdirSync(folder)) {
|
|
97
|
+
if (f.endsWith('.tmp')) {
|
|
98
|
+
try { fs.unlinkSync(path.join(folder, f)); } catch {}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
|
|
103
|
+
const readData = async (file: string): Promise<any> => {
|
|
104
|
+
try {
|
|
105
|
+
const data = await queued(filePath(file), () => readFile(filePath(file), { encoding: 'utf-8' }));
|
|
106
|
+
return JSON.parse(data, BufferJSON.reviver);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const writeData = (data: unknown, file: string): Promise<void> => {
|
|
113
|
+
// Snapshot synchronously — the creds object mutates while writes are queued
|
|
114
|
+
const json = JSON.stringify(data, BufferJSON.replacer);
|
|
115
|
+
return queued(filePath(file), () => writeAtomic(filePath(file), json));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const removeData = (file: string): Promise<void> =>
|
|
119
|
+
queued(filePath(file), async () => {
|
|
120
|
+
try { await unlink(filePath(file)); } catch {}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── Load creds, recovering from backup when the primary is missing/corrupt ──
|
|
124
|
+
let creds: AuthenticationCreds | null = await readData('creds.json');
|
|
125
|
+
let restoredFromBackup = false;
|
|
126
|
+
if (!isValidCreds(creds)) {
|
|
127
|
+
const backup = await readData('creds.json.bak');
|
|
128
|
+
if (isValidCreds(backup)) {
|
|
129
|
+
creds = backup;
|
|
130
|
+
restoredFromBackup = true;
|
|
131
|
+
await writeData(backup, 'creds.json');
|
|
132
|
+
log.warn('[whatsapp] creds.json was missing or corrupt — restored from creds.json.bak');
|
|
133
|
+
} else {
|
|
134
|
+
if (fs.existsSync(filePath('creds.json'))) {
|
|
135
|
+
// Present but unreadable and no usable backup: quarantine it so the dead
|
|
136
|
+
// session isn't half-trusted, and start a clean pairing flow.
|
|
137
|
+
const quarantine = filePath(`creds.json.corrupt-${Date.now()}`);
|
|
138
|
+
try { await rename(filePath('creds.json'), quarantine); } catch {}
|
|
139
|
+
log.warn(`[whatsapp] creds.json is corrupt with no backup — quarantined as ${path.basename(quarantine)}; re-link required`);
|
|
140
|
+
}
|
|
141
|
+
// The corrupt backup must go too, or hasValidCredsFile() keeps presenting the
|
|
142
|
+
// dead session as "linked" on every future boot.
|
|
143
|
+
if (fs.existsSync(filePath('creds.json.bak'))) {
|
|
144
|
+
try { await rename(filePath('creds.json.bak'), filePath(`creds.json.bak.corrupt-${Date.now()}`)); } catch {}
|
|
145
|
+
}
|
|
146
|
+
creds = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const freshIdentity = !creds;
|
|
150
|
+
if (freshIdentity) {
|
|
151
|
+
// Any key material on disk is bound to a dead identity — sweep it so the fresh
|
|
152
|
+
// pairing starts clean instead of tripping over stale sessions and pre-keys.
|
|
153
|
+
// (Quarantined creds files are kept for forensics.)
|
|
154
|
+
try {
|
|
155
|
+
for (const f of fs.readdirSync(folder)) {
|
|
156
|
+
if (f === 'creds.json' || f.includes('.corrupt-')) continue;
|
|
157
|
+
try { fs.unlinkSync(path.join(folder, f)); } catch {}
|
|
158
|
+
}
|
|
159
|
+
} catch {}
|
|
160
|
+
}
|
|
161
|
+
const liveCreds: AuthenticationCreds = creds || initAuthCreds();
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
state: {
|
|
165
|
+
creds: liveCreds,
|
|
166
|
+
keys: {
|
|
167
|
+
get: async (type, ids) => {
|
|
168
|
+
const data: { [_: string]: SignalDataTypeMap[typeof type] } = {};
|
|
169
|
+
await Promise.all(
|
|
170
|
+
ids.map(async (id) => {
|
|
171
|
+
let value = await readData(`${type}-${id}.json`);
|
|
172
|
+
if (type === 'app-state-sync-key' && value) {
|
|
173
|
+
value = proto.Message.AppStateSyncKeyData.fromObject(value);
|
|
174
|
+
}
|
|
175
|
+
data[id] = value;
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
return data;
|
|
179
|
+
},
|
|
180
|
+
set: async (data) => {
|
|
181
|
+
const tasks: Promise<void>[] = [];
|
|
182
|
+
for (const category in data) {
|
|
183
|
+
const entries = data[category as keyof SignalDataTypeMap];
|
|
184
|
+
if (!entries) continue;
|
|
185
|
+
for (const id in entries) {
|
|
186
|
+
const value = entries[id];
|
|
187
|
+
const file = `${category}-${id}.json`;
|
|
188
|
+
tasks.push(value ? writeData(value, file) : removeData(file));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
await Promise.all(tasks);
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
saveCreds: async () => {
|
|
196
|
+
// One snapshot for both files so primary and backup are always identical
|
|
197
|
+
const json = JSON.stringify(liveCreds, BufferJSON.replacer);
|
|
198
|
+
await queued(filePath('creds.json'), () => writeAtomic(filePath('creds.json'), json));
|
|
199
|
+
await queued(filePath('creds.json.bak'), () => writeAtomic(filePath('creds.json.bak'), json));
|
|
200
|
+
},
|
|
201
|
+
restoredFromBackup,
|
|
202
|
+
freshIdentity,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Sync check used by status endpoints: a linked session exists when creds.json (or its
|
|
207
|
+
* backup) is present and non-empty. A 0-byte creds.json — the artifact of an interrupted
|
|
208
|
+
* legacy write — no longer counts as "linked". */
|
|
209
|
+
export function hasValidCredsFile(folder: string): boolean {
|
|
210
|
+
for (const name of ['creds.json', 'creds.json.bak']) {
|
|
211
|
+
try {
|
|
212
|
+
if (fs.statSync(path.join(folder, name)).size > 0) return true;
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import makeWASocket, {
|
|
7
|
-
useMultiFileAuthState,
|
|
8
7
|
makeCacheableSignalKeyStore,
|
|
9
|
-
|
|
8
|
+
fetchLatestBaileysVersion,
|
|
10
9
|
downloadMediaMessage,
|
|
11
10
|
DisconnectReason,
|
|
12
11
|
Browsers,
|
|
@@ -20,6 +19,7 @@ import QRCode from 'qrcode';
|
|
|
20
19
|
import pino from 'pino';
|
|
21
20
|
import { DATA_DIR } from '../../shared/paths.js';
|
|
22
21
|
import { log } from '../../shared/logger.js';
|
|
22
|
+
import { useAtomicMultiFileAuthState, hasValidCredsFile, flushAuthWrites } from './whatsapp-auth.js';
|
|
23
23
|
import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';
|
|
24
24
|
|
|
25
25
|
const AUTH_DIR = path.join(DATA_DIR, 'channels', 'whatsapp', 'auth');
|
|
@@ -63,6 +63,17 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
63
63
|
private transcribe: TranscribeFn | null = null;
|
|
64
64
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
65
65
|
private intentionalDisconnect = false;
|
|
66
|
+
/** Monotonic token — bumping it invalidates any in-flight connectInternal(), so a
|
|
67
|
+
* pending reconnect timer and a manual connect can't race into two live sockets
|
|
68
|
+
* (two sockets on one auth dir = 440 conflict loops + divergent creds writes). */
|
|
69
|
+
private connectGen = 0;
|
|
70
|
+
/** Consecutive failed reconnects — drives backoff; reset on successful open */
|
|
71
|
+
private reconnectAttempts = 0;
|
|
72
|
+
/** Auto-regenerated QR windows since the last explicit connect — caps the
|
|
73
|
+
* unattended pairing loop instead of cycling QR codes in the background forever */
|
|
74
|
+
private pairingRetries = 0;
|
|
75
|
+
/** Last pending creds write — drained on disconnect so process exit can't clip it */
|
|
76
|
+
private lastCredsWrite: Promise<unknown> = Promise.resolve();
|
|
66
77
|
|
|
67
78
|
/** IDs of messages we sent — used to prevent echo loops */
|
|
68
79
|
private sentMessageIds = new Set<string>();
|
|
@@ -87,11 +98,29 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
87
98
|
|
|
88
99
|
async connect(): Promise<void> {
|
|
89
100
|
this.intentionalDisconnect = false;
|
|
101
|
+
this.reconnectAttempts = 0; // explicit connect starts a fresh backoff ladder
|
|
102
|
+
this.pairingRetries = 0;
|
|
90
103
|
await this.connectInternal();
|
|
91
104
|
}
|
|
92
105
|
|
|
106
|
+
/** Arm the reconnect timer. connectInternal can reject (disk errors during auth
|
|
107
|
+
* load) — without the catch + re-arm, a timer-driven failure would strand the
|
|
108
|
+
* channel offline with no retry until someone manually reconnects. */
|
|
109
|
+
private scheduleReconnect(delay: number): void {
|
|
110
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
111
|
+
this.reconnectTimer = setTimeout(() => {
|
|
112
|
+
this.connectInternal().catch((err: any) => {
|
|
113
|
+
if (this.intentionalDisconnect) return;
|
|
114
|
+
const next = Math.min(5000 * 2 ** this.reconnectAttempts++, 60_000);
|
|
115
|
+
log.warn(`[whatsapp] Reconnect attempt failed: ${err.message} — retrying in ${Math.round(next / 1000)}s`);
|
|
116
|
+
this.scheduleReconnect(next);
|
|
117
|
+
});
|
|
118
|
+
}, delay);
|
|
119
|
+
}
|
|
120
|
+
|
|
93
121
|
async disconnect(): Promise<void> {
|
|
94
122
|
this.intentionalDisconnect = true;
|
|
123
|
+
this.connectGen++; // invalidate any connectInternal() still in flight
|
|
95
124
|
if (this.reconnectTimer) {
|
|
96
125
|
clearTimeout(this.reconnectTimer);
|
|
97
126
|
this.reconnectTimer = null;
|
|
@@ -103,6 +132,12 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
103
132
|
// Clear all typing intervals
|
|
104
133
|
for (const interval of this.typingIntervals.values()) clearInterval(interval);
|
|
105
134
|
this.typingIntervals.clear();
|
|
135
|
+
// Let in-flight auth writes (creds + signal keys) land before callers proceed to
|
|
136
|
+
// process.exit — writes are atomic now, but a lost update rolls session keys back.
|
|
137
|
+
await Promise.race([
|
|
138
|
+
Promise.all([this.lastCredsWrite, flushAuthWrites(AUTH_DIR)]),
|
|
139
|
+
new Promise((r) => setTimeout(r, 2000)),
|
|
140
|
+
]);
|
|
106
141
|
this.connected = false;
|
|
107
142
|
this.qrData = null;
|
|
108
143
|
this.qrSvg = null;
|
|
@@ -292,7 +327,9 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
292
327
|
}
|
|
293
328
|
|
|
294
329
|
hasCredentials(): boolean {
|
|
295
|
-
|
|
330
|
+
// Validates content, not just existence — a 0-byte creds.json (interrupted legacy
|
|
331
|
+
// write) must not count as "linked", or boot lands in a silent QR loop.
|
|
332
|
+
return hasValidCredsFile(AUTH_DIR);
|
|
296
333
|
}
|
|
297
334
|
|
|
298
335
|
/** Delete stored credentials (for re-auth / logout) */
|
|
@@ -358,6 +395,14 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
358
395
|
}
|
|
359
396
|
|
|
360
397
|
private async connectInternal(): Promise<void> {
|
|
398
|
+
const gen = ++this.connectGen;
|
|
399
|
+
// A pending reconnect must not fire on top of this attempt — clear it so the
|
|
400
|
+
// timer and a manual connect can't produce two sockets on the same auth dir.
|
|
401
|
+
if (this.reconnectTimer) {
|
|
402
|
+
clearTimeout(this.reconnectTimer);
|
|
403
|
+
this.reconnectTimer = null;
|
|
404
|
+
}
|
|
405
|
+
|
|
361
406
|
// Clean up any existing socket before creating a new one
|
|
362
407
|
if (this.sock) {
|
|
363
408
|
try { this.sock.ev.removeAllListeners('creds.update'); } catch {}
|
|
@@ -370,18 +415,26 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
370
415
|
// Ensure auth directory exists
|
|
371
416
|
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
|
372
417
|
|
|
373
|
-
|
|
418
|
+
// Atomic-write auth state — survives the kills/crashes that used to truncate
|
|
419
|
+
// creds.json, and recovers from creds.json.bak when the primary is corrupt.
|
|
420
|
+
const { state, saveCreds, freshIdentity } = await useAtomicMultiFileAuthState(AUTH_DIR);
|
|
421
|
+
if (gen !== this.connectGen || this.intentionalDisconnect) return; // superseded while loading auth
|
|
422
|
+
if (freshIdentity) log.info('[whatsapp] No valid credentials — starting pairing flow');
|
|
374
423
|
|
|
375
424
|
// Suppress Baileys' noisy logging
|
|
376
425
|
const logger = pino({ level: 'silent' }) as any;
|
|
377
426
|
|
|
378
427
|
let version: [number, number, number] | undefined;
|
|
379
428
|
try {
|
|
380
|
-
|
|
429
|
+
// fetchLatestBaileysVersion = newest WA Web version the LIBRARY supports.
|
|
430
|
+
// (fetchLatestWaWebVersion scrapes web.whatsapp.com and can return a protocol
|
|
431
|
+
// revision newer than Baileys handles — a disconnect source.)
|
|
432
|
+
const result = await fetchLatestBaileysVersion();
|
|
381
433
|
version = result.version;
|
|
382
434
|
} catch {
|
|
383
435
|
log.warn('[whatsapp] Could not fetch latest WA version — using default');
|
|
384
436
|
}
|
|
437
|
+
if (gen !== this.connectGen || this.intentionalDisconnect) return; // superseded during version fetch
|
|
385
438
|
|
|
386
439
|
const sock = makeWASocket({
|
|
387
440
|
auth: {
|
|
@@ -390,18 +443,27 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
390
443
|
},
|
|
391
444
|
version,
|
|
392
445
|
browser: Browsers.macOS('Chrome'),
|
|
393
|
-
printQRInTerminal: false,
|
|
394
446
|
logger,
|
|
447
|
+
markOnlineOnConnect: false, // keep message notifications on the phone
|
|
395
448
|
generateHighQualityLinkPreview: false,
|
|
449
|
+
// Baileys 7.x retry handshake: without this, inbound messages that need an
|
|
450
|
+
// E2EE session re-establishment are silently dropped (msg.message === null).
|
|
451
|
+
getMessage: async () => ({ conversation: '' }),
|
|
396
452
|
});
|
|
397
453
|
|
|
398
454
|
this.sock = sock;
|
|
399
455
|
|
|
400
|
-
// Persist credential updates
|
|
401
|
-
|
|
456
|
+
// Persist credential updates (fires constantly while connected — key rotations,
|
|
457
|
+
// app-state sync). Track the promise so disconnect() can drain it before exit.
|
|
458
|
+
sock.ev.on('creds.update', () => {
|
|
459
|
+
this.lastCredsWrite = saveCreds().catch((err: any) => {
|
|
460
|
+
log.warn(`[whatsapp] Failed to persist credentials: ${err.message}`);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
402
463
|
|
|
403
464
|
// Connection state changes
|
|
404
465
|
sock.ev.on('connection.update', async (update) => {
|
|
466
|
+
if (this.sock !== sock) return; // stale event from a superseded socket
|
|
405
467
|
const { connection, lastDisconnect, qr } = update;
|
|
406
468
|
|
|
407
469
|
// QR code received — render to SVG
|
|
@@ -418,6 +480,7 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
418
480
|
|
|
419
481
|
if (connection === 'open') {
|
|
420
482
|
this.connected = true;
|
|
483
|
+
this.reconnectAttempts = 0;
|
|
421
484
|
this.qrData = null;
|
|
422
485
|
this.qrSvg = null;
|
|
423
486
|
this.buildLidMap();
|
|
@@ -453,9 +516,41 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
453
516
|
return;
|
|
454
517
|
}
|
|
455
518
|
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
519
|
+
// Never paired (pair-success sets creds.registered before the post-pairing 515,
|
|
520
|
+
// so a registered=false close means the pairing window expired unused).
|
|
521
|
+
if (!state.creds.registered) {
|
|
522
|
+
if (state.creds.me) {
|
|
523
|
+
// Phantom identity from an uncompleted pairing-code attempt (Baileys sets
|
|
524
|
+
// creds.me as a placeholder when the code is requested). Reconnecting would
|
|
525
|
+
// try to LOG IN with an identity the server never registered → guaranteed
|
|
526
|
+
// 401 wipe. Reset to a clean unpaired state instead.
|
|
527
|
+
log.info('[whatsapp] Pairing code expired before confirmation — resetting credentials; connect again to retry');
|
|
528
|
+
await this.deleteCredentials();
|
|
529
|
+
} else if (this.pairingRetries < 2) {
|
|
530
|
+
// Fresh QR windows for a while (the link page is likely still open),
|
|
531
|
+
// then stop instead of cycling QR codes in the background forever.
|
|
532
|
+
this.pairingRetries++;
|
|
533
|
+
log.info(`[whatsapp] QR expired — generating a fresh one (retry ${this.pairingRetries}/2)`);
|
|
534
|
+
this.emitStatus();
|
|
535
|
+
this.scheduleReconnect(2000);
|
|
536
|
+
return;
|
|
537
|
+
} else {
|
|
538
|
+
log.info('[whatsapp] Pairing window closed without a scan — connect again to retry');
|
|
539
|
+
}
|
|
540
|
+
this.emitStatus();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// restartRequired (515) is the normal post-pairing handoff — reconnect right
|
|
545
|
+
// away, but only the first time (a repeated-515 loop is a known Baileys failure
|
|
546
|
+
// mode; let it back off). Everything else backs off 5s → 60s so an offline
|
|
547
|
+
// network doesn't hot-loop.
|
|
548
|
+
const fastRestart = statusCode === DisconnectReason.restartRequired && this.reconnectAttempts === 0;
|
|
549
|
+
const delay = fastRestart ? 1000 : Math.min(5000 * 2 ** this.reconnectAttempts, 60_000);
|
|
550
|
+
this.reconnectAttempts++;
|
|
551
|
+
log.info(`[whatsapp] Reconnecting in ${Math.round(delay / 1000)}s...`);
|
|
552
|
+
this.emitStatus();
|
|
553
|
+
this.scheduleReconnect(delay);
|
|
459
554
|
}
|
|
460
555
|
});
|
|
461
556
|
|
package/supervisor/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http from 'http';
|
|
2
2
|
import net from 'net';
|
|
3
3
|
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
4
5
|
import path from 'path';
|
|
5
6
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
6
7
|
import { loadConfig, saveConfig } from '../shared/config.js';
|
|
@@ -1000,6 +1001,7 @@ ${connected
|
|
|
1000
1001
|
${!connected ? `<script>
|
|
1001
1002
|
// Poll for status changes instead of full page reload (avoids SW reinstall cycle)
|
|
1002
1003
|
let polling=true;
|
|
1004
|
+
let qrMisses=0;
|
|
1003
1005
|
async function poll(){
|
|
1004
1006
|
if(!polling) return;
|
|
1005
1007
|
try{
|
|
@@ -1012,10 +1014,16 @@ ${!connected ? `<script>
|
|
|
1012
1014
|
const wa=Array.isArray(statuses)?statuses.find(s=>s.channel==='whatsapp'):null;
|
|
1013
1015
|
if(wa?.connected){location.reload();return}
|
|
1014
1016
|
if(qrData.qr){
|
|
1017
|
+
qrMisses=0;
|
|
1015
1018
|
const inner=document.querySelector('.qr-inner');
|
|
1016
1019
|
if(inner) inner.innerHTML=qrData.qr;
|
|
1017
1020
|
const loading=document.querySelector('.loading');
|
|
1018
1021
|
if(loading) loading.remove();
|
|
1022
|
+
}else if(wa&&!wa.connected&&++qrMisses>=3){
|
|
1023
|
+
// QR window expired and background retries stopped — this page being open means
|
|
1024
|
+
// the user is still trying to link, so restart pairing for a fresh code.
|
|
1025
|
+
qrMisses=0;
|
|
1026
|
+
fetch('/api/channels/whatsapp/connect',{method:'POST'}).catch(()=>{});
|
|
1019
1027
|
}
|
|
1020
1028
|
}catch{}
|
|
1021
1029
|
setTimeout(poll,3000);
|
|
@@ -3578,11 +3586,40 @@ ${alreadyLinked ? '' : `
|
|
|
3578
3586
|
server.listen(config.port, () => {
|
|
3579
3587
|
log.ok(`Supervisor on http://localhost:${config.port}`);
|
|
3580
3588
|
log.ok(`Bloby chat at http://localhost:${config.port}/bloby`);
|
|
3589
|
+
writeRuntimeFile();
|
|
3581
3590
|
if (config.tunnel.mode === 'off') {
|
|
3582
3591
|
console.log('__READY__');
|
|
3583
3592
|
}
|
|
3584
3593
|
});
|
|
3585
3594
|
|
|
3595
|
+
// ~/.bloby/supervisor.json — the CLI's single source of truth for "is Bloby
|
|
3596
|
+
// running and which process is it". Written here (not by the CLI) because the
|
|
3597
|
+
// supervisor has at least four different parents (cli foreground, launchd,
|
|
3598
|
+
// systemd, docker) and only the supervisor itself is present in all of them.
|
|
3599
|
+
// The CLI validates pid liveness + command line, so a stale file after kill -9
|
|
3600
|
+
// is harmless.
|
|
3601
|
+
function writeRuntimeFile() {
|
|
3602
|
+
try {
|
|
3603
|
+
let version = 'unknown';
|
|
3604
|
+
try { version = JSON.parse(fs.readFileSync(path.join(PKG_DIR, 'package.json'), 'utf-8')).version || 'unknown'; } catch {}
|
|
3605
|
+
fs.writeFileSync(path.join(DATA_DIR, 'supervisor.json'), JSON.stringify({
|
|
3606
|
+
pid: process.pid,
|
|
3607
|
+
startedAt: Date.now(),
|
|
3608
|
+
version,
|
|
3609
|
+
port: config.port,
|
|
3610
|
+
parent: process.env.INVOCATION_ID ? 'systemd' : (process.env.XPC_SERVICE_NAME?.includes('bloby') ? 'launchd' : 'other'),
|
|
3611
|
+
}, null, 2));
|
|
3612
|
+
} catch { /* best-effort — the CLI falls back to launchd/systemd queries */ }
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
function removeRuntimeFile() {
|
|
3616
|
+
try {
|
|
3617
|
+
const p = path.join(DATA_DIR, 'supervisor.json');
|
|
3618
|
+
const cur = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
3619
|
+
if (cur.pid === process.pid) fs.unlinkSync(p); // never delete a newer instance's file
|
|
3620
|
+
} catch {}
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3586
3623
|
// Track whether an agent is actively processing — file watcher defers restarts during active turns
|
|
3587
3624
|
let agentQueryActive = false;
|
|
3588
3625
|
let pendingBackendRestart = false; // Set when file watcher fires during agent turn
|
|
@@ -3683,8 +3720,14 @@ ${alreadyLinked ? '' : `
|
|
|
3683
3720
|
try { fs.closeSync(logFd); } catch {}
|
|
3684
3721
|
if (code === 0) {
|
|
3685
3722
|
clearUpdateMarker(); // success (updated or already-latest) — don't re-run on the next boot
|
|
3686
|
-
log.ok('Update completed —
|
|
3687
|
-
|
|
3723
|
+
log.ok('Update completed — relaunching daemon onto the new version...');
|
|
3724
|
+
relaunchSupervisor();
|
|
3725
|
+
// NO process.exit here: the old reliance on launchd KeepAlive after exit(1) does NOT fire
|
|
3726
|
+
// when the supervisor runs in the foreground or the launchd job isn't loaded (the user hit
|
|
3727
|
+
// exactly this). relaunchSupervisor() actively reloads the daemon (mirroring the battle-
|
|
3728
|
+
// tested manual `bloby update` path); its `launchctl unload` / the new daemon's killPort
|
|
3729
|
+
// terminates THIS old process. If the daemon isn't installed at all (pure foreground dev),
|
|
3730
|
+
// the relaunch no-ops and we stay up on the old code rather than going down unrecoverably.
|
|
3688
3731
|
} else {
|
|
3689
3732
|
// Leave the marker so the next boot retries (bounded by attempts); allow another flush now.
|
|
3690
3733
|
updateInProgress = false;
|
|
@@ -3702,6 +3745,51 @@ ${alreadyLinked ? '' : `
|
|
|
3702
3745
|
}
|
|
3703
3746
|
}
|
|
3704
3747
|
|
|
3748
|
+
/** Relaunch the supervisor onto freshly-updated code by actively (re)loading the daemon — the same
|
|
3749
|
+
* `launchctl unload/load` (macOS) / `systemctl restart` (Linux) that the battle-tested manual
|
|
3750
|
+
* `bloby update` uses. Spawned DETACHED + unref'd so it OUTLIVES this process when the relaunch's
|
|
3751
|
+
* unload terminates us. `sleep 1` lets the final HTTP ack flush first. If the daemon isn't
|
|
3752
|
+
* installed (foreground dev with no plist), `bloby daemon restart` no-ops — we then stay up on the
|
|
3753
|
+
* current code instead of going down with nothing to bring us back. Replaces the previous reliance
|
|
3754
|
+
* on launchd KeepAlive after process.exit, which never fires in the foreground / when unloaded. */
|
|
3755
|
+
function relaunchSupervisor(): void {
|
|
3756
|
+
// Under systemd we ARE the unit's main process: a non-zero exit triggers
|
|
3757
|
+
// Restart=on-failure and systemd respawns us onto the new code in ~5s. The
|
|
3758
|
+
// old detached `bloby daemon restart` path is a dead end here — it needs
|
|
3759
|
+
// sudo for systemctl and there is no TTY to ask for a password.
|
|
3760
|
+
if (process.env.INVOCATION_ID) {
|
|
3761
|
+
log.ok('Updated — exiting so systemd restarts the unit onto the new version...');
|
|
3762
|
+
try { removeRuntimeFile(); } catch {}
|
|
3763
|
+
try { const cfg = loadConfig(); delete cfg.tunnelUrl; saveConfig(cfg); } catch {}
|
|
3764
|
+
setTimeout(() => process.exit(1), 1000); // let the final HTTP ack flush
|
|
3765
|
+
return;
|
|
3766
|
+
}
|
|
3767
|
+
// No service installed (pure foreground dev, no plist/unit): `bloby restart`
|
|
3768
|
+
// now consolidates foreground instances INTO the daemon, so spawning it here
|
|
3769
|
+
// would kill this process and silently install a service nobody asked for.
|
|
3770
|
+
// Stay up on the old code instead — the human restarts when they choose.
|
|
3771
|
+
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.bloby.bot.plist');
|
|
3772
|
+
const unitPath = '/etc/systemd/system/bloby.service';
|
|
3773
|
+
if (!fs.existsSync(process.platform === 'darwin' ? plistPath : unitPath)) {
|
|
3774
|
+
log.warn('Updated, but no background service is installed — restart Bloby manually to load the new version.');
|
|
3775
|
+
return;
|
|
3776
|
+
}
|
|
3777
|
+
const cliPath = path.join(PKG_DIR, 'bin', 'cli.js');
|
|
3778
|
+
try {
|
|
3779
|
+
// `bloby daemon restart` boots the launchd job out and bootstraps it again; the
|
|
3780
|
+
// bootout terminates THIS process, so the relauncher must be detached + unref'd
|
|
3781
|
+
// to outlive it.
|
|
3782
|
+
const relauncher = cpSpawn(process.execPath, [cliPath, 'daemon', 'restart'], {
|
|
3783
|
+
detached: true,
|
|
3784
|
+
stdio: 'ignore',
|
|
3785
|
+
env: { ...process.env },
|
|
3786
|
+
});
|
|
3787
|
+
relauncher.unref();
|
|
3788
|
+
} catch (err) {
|
|
3789
|
+
log.error(`Relaunch failed to spawn: ${err instanceof Error ? err.message : err} — run \`bloby restart\` manually.`);
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3705
3793
|
/** On boot, resume an update that was queued but never ran (supervisor died in the request→flush
|
|
3706
3794
|
* window). Safe to auto-run: bin/cli.js update version-checks and no-ops if already latest, and
|
|
3707
3795
|
* the marker's TTL + attempts cap prevent a restart loop. */
|
|
@@ -4083,6 +4171,7 @@ ${alreadyLinked ? '' : `
|
|
|
4083
4171
|
stopTunnel();
|
|
4084
4172
|
await stopViteDevServers();
|
|
4085
4173
|
server.close();
|
|
4174
|
+
removeRuntimeFile();
|
|
4086
4175
|
process.exit(0);
|
|
4087
4176
|
};
|
|
4088
4177
|
process.on('SIGINT', () => shutdown());
|
package/tsconfig.json
CHANGED
|
@@ -15,6 +15,6 @@
|
|
|
15
15
|
"@client/*": ["./workspace/client/src/*"]
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
|
-
"include": ["server/**/*", "workspace/client/src/**/*", "workspace/backend/**/*", "
|
|
18
|
+
"include": ["server/**/*", "workspace/client/src/**/*", "workspace/backend/**/*", "vite.config.ts"],
|
|
19
19
|
"exclude": ["node_modules", "dist", "data"]
|
|
20
20
|
}
|