owpenwork 0.1.11 → 0.1.13

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/dist/bridge.js CHANGED
@@ -19,7 +19,8 @@ const TOOL_LABELS = {
19
19
  task: "agent",
20
20
  webfetch: "webfetch",
21
21
  };
22
- export async function startBridge(config, logger) {
22
+ export async function startBridge(config, logger, reporter) {
23
+ const reportStatus = reporter?.onStatus;
23
24
  const client = createClient(config);
24
25
  const store = new BridgeStore(config.dbPath);
25
26
  store.seedAllowlist("telegram", config.allowlist.telegram);
@@ -31,12 +32,14 @@ export async function startBridge(config, logger) {
31
32
  }
32
33
  else {
33
34
  logger.info("telegram adapter disabled");
35
+ reportStatus?.("Telegram adapter disabled.");
34
36
  }
35
37
  if (config.whatsappEnabled) {
36
- adapters.set("whatsapp", createWhatsAppAdapter(config, logger, handleInbound, { printQr: true }));
38
+ adapters.set("whatsapp", createWhatsAppAdapter(config, logger, handleInbound, { printQr: true, onStatus: reportStatus }));
37
39
  }
38
40
  else {
39
41
  logger.info("whatsapp adapter disabled");
42
+ reportStatus?.("WhatsApp adapter disabled.");
40
43
  }
41
44
  const sessionQueue = new Map();
42
45
  const activeRuns = new Map();
@@ -102,7 +105,7 @@ export async function startBridge(config, logger) {
102
105
  if (output)
103
106
  message += `\n${output}`;
104
107
  }
105
- await sendText(run.channel, run.peerId, message);
108
+ await sendText(run.channel, run.peerId, message, { kind: "tool" });
106
109
  }
107
110
  if (event.type === "permission.asked") {
108
111
  const permission = event.properties;
@@ -117,7 +120,9 @@ export async function startBridge(config, logger) {
117
120
  if (response === "reject") {
118
121
  const run = activeRuns.get(permission.sessionID);
119
122
  if (run) {
120
- await sendText(run.channel, run.peerId, "Permission denied. Update configuration to allow tools.");
123
+ await sendText(run.channel, run.peerId, "Permission denied. Update configuration to allow tools.", {
124
+ kind: "system",
125
+ });
121
126
  }
122
127
  }
123
128
  }
@@ -125,10 +130,14 @@ export async function startBridge(config, logger) {
125
130
  })().catch((error) => {
126
131
  logger.error({ error }, "event stream closed");
127
132
  });
128
- async function sendText(channel, peerId, text) {
133
+ async function sendText(channel, peerId, text, options = {}) {
129
134
  const adapter = adapters.get(channel);
130
135
  if (!adapter)
131
136
  return;
137
+ const kind = options.kind ?? "system";
138
+ if (options.display !== false) {
139
+ reporter?.onOutbound?.({ channel, peerId, text, kind });
140
+ }
132
141
  const chunks = chunkText(text, adapter.maxTextLength);
133
142
  for (const chunk of chunks) {
134
143
  logger.info({ channel, peerId, length: chunk.length }, "sending message");
@@ -151,30 +160,36 @@ export async function startBridge(config, logger) {
151
160
  const allowed = allowAll || isSelf || store.isAllowed("whatsapp", peerKey);
152
161
  if (!allowed) {
153
162
  if (config.whatsappDmPolicy === "allowlist") {
154
- await sendText(inbound.channel, inbound.peerId, "Access denied. Ask the owner to allowlist your number.");
163
+ await sendText(inbound.channel, inbound.peerId, "Access denied. Ask the owner to allowlist your number.", { kind: "system" });
155
164
  return;
156
165
  }
157
166
  store.prunePairingRequests();
158
167
  const active = store.getPairingRequest("whatsapp", peerKey);
159
168
  const pending = store.listPairingRequests("whatsapp");
160
169
  if (!active && pending.length >= 3) {
161
- await sendText(inbound.channel, inbound.peerId, "Pairing queue full. Ask the owner to approve pending requests.");
170
+ await sendText(inbound.channel, inbound.peerId, "Pairing queue full. Ask the owner to approve pending requests.", { kind: "system" });
162
171
  return;
163
172
  }
164
173
  const code = active?.code ?? String(Math.floor(100000 + Math.random() * 900000));
165
174
  if (!active) {
166
175
  store.createPairingRequest("whatsapp", peerKey, code, 60 * 60_000);
167
176
  }
168
- await sendText(inbound.channel, inbound.peerId, `Pairing required. Ask the owner to approve code: ${code}`);
177
+ await sendText(inbound.channel, inbound.peerId, `Pairing required. Ask the owner to approve code: ${code}`, { kind: "system" });
169
178
  return;
170
179
  }
171
180
  }
172
181
  else if (config.allowlist[inbound.channel].size > 0) {
173
182
  if (!store.isAllowed(inbound.channel, peerKey)) {
174
- await sendText(inbound.channel, inbound.peerId, "Access denied.");
183
+ await sendText(inbound.channel, inbound.peerId, "Access denied.", { kind: "system" });
175
184
  return;
176
185
  }
177
186
  }
187
+ reporter?.onInbound?.({
188
+ channel: inbound.channel,
189
+ peerId: inbound.peerId,
190
+ text: inbound.text,
191
+ fromMe: inbound.fromMe,
192
+ });
178
193
  const session = store.getSession(inbound.channel, peerKey);
179
194
  const sessionID = session?.session_id ?? (await createSession({ ...inbound, peerId: peerKey }));
180
195
  enqueue(sessionID, async () => {
@@ -198,15 +213,19 @@ export async function startBridge(config, logger) {
198
213
  .join("\n")
199
214
  .trim();
200
215
  if (reply) {
201
- await sendText(inbound.channel, inbound.peerId, reply);
216
+ await sendText(inbound.channel, inbound.peerId, reply, { kind: "reply" });
202
217
  }
203
218
  else {
204
- await sendText(inbound.channel, inbound.peerId, "No response generated. Try again.");
219
+ await sendText(inbound.channel, inbound.peerId, "No response generated. Try again.", {
220
+ kind: "system",
221
+ });
205
222
  }
206
223
  }
207
224
  catch (error) {
208
225
  logger.error({ error }, "prompt failed");
209
- await sendText(inbound.channel, inbound.peerId, "Error: failed to reach OpenCode.");
226
+ await sendText(inbound.channel, inbound.peerId, "Error: failed to reach OpenCode.", {
227
+ kind: "system",
228
+ });
210
229
  }
211
230
  finally {
212
231
  activeRuns.delete(sessionID);
@@ -242,8 +261,10 @@ export async function startBridge(config, logger) {
242
261
  }
243
262
  for (const adapter of adapters.values()) {
244
263
  await adapter.start();
264
+ reportStatus?.(`${adapter.name === "whatsapp" ? "WhatsApp" : "Telegram"} adapter started.`);
245
265
  }
246
266
  logger.info({ channels: Array.from(adapters.keys()) }, "bridge started");
267
+ reportStatus?.(`Bridge running. Logs: ${config.logFile}`);
247
268
  return {
248
269
  async stop() {
249
270
  eventAbort.abort();
package/dist/cli.js CHANGED
@@ -8,8 +8,9 @@ import { loadConfig, normalizeWhatsAppId, readConfigFile, writeConfigFile, } fro
8
8
  import { BridgeStore } from "./db.js";
9
9
  import { createLogger } from "./logger.js";
10
10
  import { createClient } from "./opencode.js";
11
+ import { truncateText } from "./text.js";
11
12
  import { loginWhatsApp, unpairWhatsApp } from "./whatsapp.js";
12
- const VERSION = "0.1.11";
13
+ const VERSION = "0.1.13";
13
14
  const STEP_OPTIONS = [
14
15
  {
15
16
  value: "config",
@@ -62,6 +63,38 @@ function defaultSelections(configExists, whatsappLinked) {
62
63
  selections.push("start");
63
64
  return selections;
64
65
  }
66
+ function createAppLogger(config) {
67
+ return createLogger(config.logLevel, { logFile: config.logFile });
68
+ }
69
+ function createConsoleReporter() {
70
+ const formatChannel = (channel) => (channel === "whatsapp" ? "WhatsApp" : "Telegram");
71
+ const formatPeer = (channel, peerId, fromMe) => {
72
+ const base = channel === "whatsapp" ? normalizeWhatsAppId(peerId) : peerId;
73
+ return fromMe ? `${base} (me)` : base;
74
+ };
75
+ const printBlock = (prefix, text) => {
76
+ const lines = text.split(/\r?\n/).map((line) => truncateText(line.trim(), 240));
77
+ const [first, ...rest] = lines.length ? lines : ["(empty)"];
78
+ console.log(`${prefix} ${first}`);
79
+ for (const line of rest) {
80
+ console.log(`${" ".repeat(prefix.length)} ${line}`);
81
+ }
82
+ };
83
+ return {
84
+ onStatus(message) {
85
+ console.log(message);
86
+ },
87
+ onInbound({ channel, peerId, text, fromMe }) {
88
+ const prefix = `[${formatChannel(channel)}] ${formatPeer(channel, peerId, fromMe)} >`;
89
+ printBlock(prefix, text);
90
+ },
91
+ onOutbound({ channel, peerId, text, kind }) {
92
+ const marker = kind === "reply" ? "<" : kind === "tool" ? "*" : "!";
93
+ const prefix = `[${formatChannel(channel)}] ${formatPeer(channel, peerId)} ${marker}`;
94
+ printBlock(prefix, text);
95
+ },
96
+ };
97
+ }
65
98
  function updateConfig(configPath, updater) {
66
99
  const { config } = readConfigFile(configPath);
67
100
  const base = config ?? { version: 1 };
@@ -195,12 +228,13 @@ async function runStart(pathOverride) {
195
228
  process.env.OPENCODE_DIRECTORY = pathOverride.trim();
196
229
  }
197
230
  const config = loadConfig();
198
- const logger = createLogger(config.logLevel);
231
+ const logger = createAppLogger(config);
232
+ const reporter = createConsoleReporter();
199
233
  if (!process.env.OPENCODE_DIRECTORY) {
200
234
  process.env.OPENCODE_DIRECTORY = config.opencodeDirectory;
201
235
  }
202
- const bridge = await startBridge(config, logger);
203
- logger.info("Commands: owpenwork whatsapp login, owpenwork pairing list, owpenwork status");
236
+ const bridge = await startBridge(config, logger, reporter);
237
+ reporter.onStatus?.("Commands: owpenwork whatsapp login, owpenwork pairing list, owpenwork status");
204
238
  const shutdown = async () => {
205
239
  logger.info("shutting down");
206
240
  await bridge.stop();
@@ -277,7 +311,7 @@ async function runGuidedFlow(pathArg, opts) {
277
311
  if (!relink)
278
312
  return;
279
313
  }
280
- await loginWhatsApp(config, createLogger(config.logLevel));
314
+ await loginWhatsApp(config, createAppLogger(config), { onStatus: console.log });
281
315
  });
282
316
  continue;
283
317
  }
@@ -338,7 +372,7 @@ login
338
372
  .description("Login to WhatsApp via QR code")
339
373
  .action(async () => {
340
374
  const config = loadConfig(process.env, { requireOpencode: false });
341
- await loginWhatsApp(config, createLogger(config.logLevel));
375
+ await loginWhatsApp(config, createAppLogger(config), { onStatus: console.log });
342
376
  });
343
377
  login
344
378
  .command("telegram")
@@ -372,28 +406,28 @@ whatsapp
372
406
  .description("Login to WhatsApp via QR code")
373
407
  .action(async () => {
374
408
  const config = loadConfig(process.env, { requireOpencode: false });
375
- await loginWhatsApp(config, createLogger(config.logLevel));
409
+ await loginWhatsApp(config, createAppLogger(config), { onStatus: console.log });
376
410
  });
377
411
  whatsapp
378
412
  .command("logout")
379
413
  .description("Logout of WhatsApp and clear auth state")
380
414
  .action(() => {
381
415
  const config = loadConfig(process.env, { requireOpencode: false });
382
- unpairWhatsApp(config, createLogger(config.logLevel));
416
+ unpairWhatsApp(config, createAppLogger(config));
383
417
  });
384
418
  program
385
419
  .command("qr")
386
420
  .description("Print a WhatsApp QR code to pair")
387
421
  .action(async () => {
388
422
  const config = loadConfig(process.env, { requireOpencode: false });
389
- await loginWhatsApp(config, createLogger(config.logLevel));
423
+ await loginWhatsApp(config, createAppLogger(config), { onStatus: console.log });
390
424
  });
391
425
  program
392
426
  .command("unpair")
393
427
  .description("Clear WhatsApp pairing data")
394
428
  .action(() => {
395
429
  const config = loadConfig(process.env, { requireOpencode: false });
396
- unpairWhatsApp(config, createLogger(config.logLevel));
430
+ unpairWhatsApp(config, createAppLogger(config));
397
431
  });
398
432
  const pairing = program.command("pairing").description("Pairing requests");
399
433
  pairing
package/dist/config.js CHANGED
@@ -125,6 +125,7 @@ export function loadConfig(env = process.env, options = {}) {
125
125
  const resolvedDirectory = opencodeDirectory || process.cwd();
126
126
  const dataDir = expandHome(env.OWPENBOT_DATA_DIR ?? "~/.owpenbot");
127
127
  const dbPath = expandHome(env.OWPENBOT_DB_PATH ?? path.join(dataDir, "owpenbot.db"));
128
+ const logFile = expandHome(env.OWPENBOT_LOG_FILE ?? path.join(dataDir, "logs", "owpenbot.log"));
128
129
  const configPath = resolveConfigPath(dataDir, env);
129
130
  const { config: configFile } = readConfigFile(configPath);
130
131
  const whatsappFile = configFile.channels?.whatsapp ?? {};
@@ -159,6 +160,7 @@ export function loadConfig(env = process.env, options = {}) {
159
160
  whatsappEnabled: parseBoolean(env.WHATSAPP_ENABLED, true),
160
161
  dataDir,
161
162
  dbPath,
163
+ logFile,
162
164
  allowlist: envAllowlist,
163
165
  toolUpdatesEnabled: parseBoolean(env.TOOL_UPDATES_ENABLED, false),
164
166
  groupsEnabled: parseBoolean(env.GROUPS_ENABLED, false),
package/dist/logger.js CHANGED
@@ -1,5 +1,12 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import pino from "pino";
2
- export function createLogger(level) {
4
+ export function createLogger(level, options) {
5
+ if (options?.logFile) {
6
+ fs.mkdirSync(path.dirname(options.logFile), { recursive: true });
7
+ const destination = pino.destination({ dest: options.logFile, sync: false });
8
+ return pino({ level, base: undefined }, destination);
9
+ }
3
10
  return pino({
4
11
  level,
5
12
  base: undefined,
package/dist/telegram.js CHANGED
@@ -20,12 +20,17 @@ export function createTelegramAdapter(config, logger, onMessage) {
20
20
  const text = msg.text ?? msg.caption ?? "";
21
21
  if (!text.trim())
22
22
  return;
23
- await onMessage({
24
- channel: "telegram",
25
- peerId: String(msg.chat.id),
26
- text,
27
- raw: msg,
28
- });
23
+ try {
24
+ await onMessage({
25
+ channel: "telegram",
26
+ peerId: String(msg.chat.id),
27
+ text,
28
+ raw: msg,
29
+ });
30
+ }
31
+ catch (error) {
32
+ logger.error({ error, peerId: msg.chat.id }, "telegram inbound handler failed");
33
+ }
29
34
  });
30
35
  return {
31
36
  name: "telegram",
@@ -0,0 +1,166 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fetchLatestBaileysVersion, makeCacheableSignalKeyStore, makeWASocket, useMultiFileAuthState, } from "@whiskeysockets/baileys";
4
+ import qrcode from "qrcode-terminal";
5
+ const CREDS_FILE = "creds.json";
6
+ const CREDS_BACKUP = "creds.json.bak";
7
+ const DEFAULT_TIMEOUT_MS = 120_000;
8
+ let credsSaveQueue = Promise.resolve();
9
+ function readCredsRaw(filePath) {
10
+ try {
11
+ if (!fs.existsSync(filePath))
12
+ return null;
13
+ const stats = fs.statSync(filePath);
14
+ if (!stats.isFile() || stats.size <= 1)
15
+ return null;
16
+ return fs.readFileSync(filePath, "utf-8");
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function isValidJson(raw) {
23
+ try {
24
+ JSON.parse(raw);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ function ensureDir(dir) {
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ }
34
+ function backupCreds(authDir, logger) {
35
+ try {
36
+ const credsPath = path.join(authDir, CREDS_FILE);
37
+ const backupPath = path.join(authDir, CREDS_BACKUP);
38
+ const raw = readCredsRaw(credsPath);
39
+ if (!raw || !isValidJson(raw))
40
+ return;
41
+ fs.copyFileSync(credsPath, backupPath);
42
+ }
43
+ catch (error) {
44
+ logger.warn({ error }, "whatsapp creds backup failed");
45
+ }
46
+ }
47
+ function maybeRestoreCreds(authDir, logger) {
48
+ try {
49
+ const credsPath = path.join(authDir, CREDS_FILE);
50
+ const backupPath = path.join(authDir, CREDS_BACKUP);
51
+ const raw = readCredsRaw(credsPath);
52
+ if (raw && isValidJson(raw))
53
+ return;
54
+ const backupRaw = readCredsRaw(backupPath);
55
+ if (!backupRaw || !isValidJson(backupRaw))
56
+ return;
57
+ fs.copyFileSync(backupPath, credsPath);
58
+ logger.warn({ credsPath }, "restored whatsapp creds from backup");
59
+ }
60
+ catch (error) {
61
+ logger.warn({ error }, "whatsapp creds restore failed");
62
+ }
63
+ }
64
+ function enqueueSaveCreds(authDir, saveCreds, logger) {
65
+ credsSaveQueue = credsSaveQueue
66
+ .then(async () => {
67
+ backupCreds(authDir, logger);
68
+ await Promise.resolve(saveCreds());
69
+ })
70
+ .catch((error) => {
71
+ logger.warn({ error }, "whatsapp creds save failed");
72
+ });
73
+ }
74
+ export function hasWhatsAppCreds(authDir) {
75
+ const raw = readCredsRaw(path.join(authDir, CREDS_FILE));
76
+ if (!raw)
77
+ return false;
78
+ return isValidJson(raw);
79
+ }
80
+ export function getStatusCode(error) {
81
+ return (error?.output?.statusCode ??
82
+ error?.error?.output?.statusCode ??
83
+ error?.status);
84
+ }
85
+ export async function createWhatsAppSocket(options) {
86
+ const { authDir, logger, printQr, onStatus, onQr } = options;
87
+ ensureDir(authDir);
88
+ maybeRestoreCreds(authDir, logger);
89
+ const { state, saveCreds } = await useMultiFileAuthState(authDir);
90
+ const { version } = await fetchLatestBaileysVersion();
91
+ const sock = makeWASocket({
92
+ auth: {
93
+ creds: state.creds,
94
+ keys: makeCacheableSignalKeyStore(state.keys, logger),
95
+ },
96
+ version,
97
+ logger,
98
+ printQRInTerminal: false,
99
+ syncFullHistory: false,
100
+ markOnlineOnConnect: false,
101
+ browser: ["owpenbot", "cli", "0.1.0"],
102
+ });
103
+ sock.ev.on("creds.update", () => enqueueSaveCreds(authDir, saveCreds, logger));
104
+ sock.ev.on("connection.update", (update) => {
105
+ if (update.qr) {
106
+ onQr?.(update.qr);
107
+ if (printQr) {
108
+ qrcode.generate(update.qr, { small: true });
109
+ onStatus?.("Scan the QR code to connect WhatsApp.");
110
+ }
111
+ }
112
+ if (update.connection === "open") {
113
+ onStatus?.("WhatsApp connected.");
114
+ }
115
+ });
116
+ sock.ws?.on?.("error", (error) => {
117
+ logger.error({ error }, "whatsapp websocket error");
118
+ });
119
+ return sock;
120
+ }
121
+ export function closeWhatsAppSocket(sock) {
122
+ try {
123
+ sock.ws?.close();
124
+ }
125
+ catch {
126
+ // ignore
127
+ }
128
+ try {
129
+ sock.end?.(undefined);
130
+ }
131
+ catch {
132
+ // ignore
133
+ }
134
+ }
135
+ export function waitForWhatsAppConnection(sock, options = {}) {
136
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
137
+ return new Promise((resolve, reject) => {
138
+ const ev = sock.ev;
139
+ let timer = null;
140
+ const cleanup = () => {
141
+ ev.off?.("connection.update", handler);
142
+ ev.removeListener?.("connection.update", handler);
143
+ if (timer)
144
+ clearTimeout(timer);
145
+ };
146
+ const handler = (...args) => {
147
+ const update = (args[0] ?? {});
148
+ if (update.connection === "open") {
149
+ cleanup();
150
+ resolve();
151
+ return;
152
+ }
153
+ if (update.connection === "close") {
154
+ cleanup();
155
+ reject(update.lastDisconnect ?? new Error("Connection closed"));
156
+ }
157
+ };
158
+ if (timeoutMs > 0) {
159
+ timer = setTimeout(() => {
160
+ cleanup();
161
+ reject(new Error("Timed out waiting for WhatsApp connection"));
162
+ }, timeoutMs);
163
+ }
164
+ sock.ev.on("connection.update", handler);
165
+ });
166
+ }
package/dist/whatsapp.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { DisconnectReason, fetchLatestBaileysVersion, isJidGroup, makeCacheableSignalKeyStore, makeWASocket, useMultiFileAuthState, } from "@whiskeysockets/baileys";
4
- import qrcode from "qrcode-terminal";
3
+ import { DisconnectReason, isJidGroup } from "@whiskeysockets/baileys";
4
+ import { closeWhatsAppSocket, createWhatsAppSocket, getStatusCode, hasWhatsAppCreds, waitForWhatsAppConnection, } from "./whatsapp-session.js";
5
5
  const MAX_TEXT_LENGTH = 3800;
6
+ const SENT_MESSAGE_TTL_MS = 10 * 60_000;
6
7
  function extractText(message) {
7
8
  const content = message.message;
8
9
  if (!content)
@@ -14,85 +15,138 @@ function extractText(message) {
14
15
  content.documentMessage?.caption ||
15
16
  "");
16
17
  }
17
- function ensureDir(dir) {
18
- fs.mkdirSync(dir, { recursive: true });
19
- }
20
18
  export function createWhatsAppAdapter(config, logger, onMessage, opts = {}) {
21
19
  let socket = null;
22
20
  let stopped = false;
21
+ let connecting = false;
22
+ let reconnectAttempts = 0;
23
+ let reconnectTimer = null;
24
+ const sentMessageIds = new Map();
23
25
  const log = logger.child({ channel: "whatsapp" });
24
26
  const authDir = path.resolve(config.whatsappAuthDir);
25
- ensureDir(authDir);
26
- async function connect() {
27
- const { state, saveCreds } = await useMultiFileAuthState(authDir);
28
- const { version } = await fetchLatestBaileysVersion();
29
- const sock = makeWASocket({
30
- auth: {
31
- creds: state.creds,
32
- keys: makeCacheableSignalKeyStore(state.keys, log),
33
- },
34
- version,
35
- logger: log,
36
- printQRInTerminal: false,
37
- syncFullHistory: false,
38
- markOnlineOnConnect: false,
39
- browser: ["owpenbot", "cli", "0.1.0"],
40
- });
41
- sock.ev.on("creds.update", saveCreds);
42
- sock.ev.on("connection.update", (update) => {
43
- if (update.qr && opts.printQr) {
44
- qrcode.generate(update.qr, { small: true });
45
- log.info("scan the QR code to connect WhatsApp");
27
+ const reconnectPolicy = {
28
+ initialMs: 1500,
29
+ maxMs: 30_000,
30
+ factor: 1.6,
31
+ jitter: 0.25,
32
+ maxAttempts: 10,
33
+ };
34
+ const computeDelay = (attempt) => {
35
+ const base = reconnectPolicy.initialMs * Math.pow(reconnectPolicy.factor, Math.max(0, attempt - 1));
36
+ const capped = Math.min(base, reconnectPolicy.maxMs);
37
+ const jitter = capped * reconnectPolicy.jitter * (Math.random() * 2 - 1);
38
+ return Math.max(250, Math.round(capped + jitter));
39
+ };
40
+ const resetReconnect = () => {
41
+ reconnectAttempts = 0;
42
+ if (reconnectTimer) {
43
+ clearTimeout(reconnectTimer);
44
+ reconnectTimer = null;
45
+ }
46
+ };
47
+ const scheduleReconnect = (statusCode) => {
48
+ if (stopped || reconnectTimer)
49
+ return;
50
+ reconnectAttempts += 1;
51
+ if (reconnectAttempts > reconnectPolicy.maxAttempts) {
52
+ log.warn({ attempts: reconnectAttempts }, "whatsapp reconnect attempts exhausted");
53
+ opts.onStatus?.("WhatsApp reconnect attempts exhausted. Run: owpenwork whatsapp login.");
54
+ return;
55
+ }
56
+ const delayMs = statusCode === 515 ? 1000 : computeDelay(reconnectAttempts);
57
+ log.warn({ delayMs, statusCode }, "whatsapp reconnect scheduled");
58
+ opts.onStatus?.(`WhatsApp reconnecting in ${Math.round(delayMs / 1000)}s...`);
59
+ reconnectTimer = setTimeout(() => {
60
+ reconnectTimer = null;
61
+ void connect({ printQr: false });
62
+ }, delayMs);
63
+ };
64
+ const recordSentMessage = (messageId) => {
65
+ if (!messageId)
66
+ return;
67
+ sentMessageIds.set(messageId, Date.now());
68
+ };
69
+ const pruneSentMessages = () => {
70
+ const now = Date.now();
71
+ for (const [id, timestamp] of sentMessageIds) {
72
+ if (now - timestamp > SENT_MESSAGE_TTL_MS) {
73
+ sentMessageIds.delete(id);
46
74
  }
47
- if (update.connection === "open") {
48
- log.info("whatsapp connected");
75
+ }
76
+ };
77
+ async function connect(options = {}) {
78
+ if (stopped || connecting)
79
+ return;
80
+ connecting = true;
81
+ try {
82
+ if (socket) {
83
+ closeWhatsAppSocket(socket);
84
+ socket = null;
49
85
  }
50
- if (update.connection === "close") {
51
- const lastDisconnect = update.lastDisconnect;
52
- const statusCode = lastDisconnect?.error?.output?.statusCode;
53
- if (statusCode === 515 && !stopped) {
54
- log.warn("whatsapp stream error; restarting connection");
55
- setTimeout(() => {
56
- void connect();
57
- }, 1000);
58
- return;
59
- }
60
- const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
61
- if (shouldReconnect && !stopped) {
62
- log.warn("whatsapp connection closed, reconnecting");
63
- void connect();
86
+ const sock = await createWhatsAppSocket({
87
+ authDir,
88
+ logger: log,
89
+ printQr: options.printQr ?? opts.printQr,
90
+ onStatus: opts.onStatus,
91
+ });
92
+ sock.ev.on("connection.update", (update) => {
93
+ if (update.connection === "open") {
94
+ resetReconnect();
64
95
  }
65
- else if (!shouldReconnect) {
66
- log.warn("whatsapp logged out, run 'owpenbot whatsapp login'");
96
+ if (update.connection === "close") {
97
+ const statusCode = getStatusCode(update.lastDisconnect?.error ?? update.lastDisconnect);
98
+ if (statusCode === DisconnectReason.loggedOut) {
99
+ log.warn("whatsapp logged out, run 'owpenbot whatsapp login'");
100
+ opts.onStatus?.("WhatsApp logged out. Run: owpenwork whatsapp login.");
101
+ return;
102
+ }
103
+ if (statusCode === 515) {
104
+ opts.onStatus?.("WhatsApp asked for a restart; reconnecting.");
105
+ }
106
+ scheduleReconnect(statusCode);
67
107
  }
68
- }
69
- });
70
- sock.ev.on("messages.upsert", async ({ messages }) => {
71
- for (const msg of messages) {
72
- if (!msg.message)
73
- continue;
74
- const fromMe = Boolean(msg.key.fromMe);
75
- if (fromMe && !config.whatsappSelfChatMode)
76
- continue;
77
- const peerId = msg.key.remoteJid;
78
- if (!peerId)
79
- continue;
80
- if (isJidGroup(peerId) && !config.groupsEnabled) {
81
- continue;
108
+ });
109
+ sock.ev.on("messages.upsert", async ({ messages }) => {
110
+ pruneSentMessages();
111
+ for (const msg of messages) {
112
+ if (!msg.message)
113
+ continue;
114
+ const fromMe = Boolean(msg.key.fromMe);
115
+ const messageId = msg.key.id;
116
+ if (fromMe && messageId && sentMessageIds.has(messageId)) {
117
+ sentMessageIds.delete(messageId);
118
+ continue;
119
+ }
120
+ if (fromMe && !config.whatsappSelfChatMode)
121
+ continue;
122
+ const peerId = msg.key.remoteJid;
123
+ if (!peerId)
124
+ continue;
125
+ if (isJidGroup(peerId) && !config.groupsEnabled) {
126
+ continue;
127
+ }
128
+ const text = extractText(msg);
129
+ if (!text.trim())
130
+ continue;
131
+ try {
132
+ await onMessage({
133
+ channel: "whatsapp",
134
+ peerId,
135
+ text,
136
+ raw: msg,
137
+ fromMe,
138
+ });
139
+ }
140
+ catch (error) {
141
+ log.error({ error, peerId }, "whatsapp inbound handler failed");
142
+ }
82
143
  }
83
- const text = extractText(msg);
84
- if (!text.trim())
85
- continue;
86
- await onMessage({
87
- channel: "whatsapp",
88
- peerId,
89
- text,
90
- raw: msg,
91
- fromMe,
92
- });
93
- }
94
- });
95
- socket = sock;
144
+ });
145
+ socket = sock;
146
+ }
147
+ finally {
148
+ connecting = false;
149
+ }
96
150
  }
97
151
  return {
98
152
  name: "whatsapp",
@@ -102,92 +156,75 @@ export function createWhatsAppAdapter(config, logger, onMessage, opts = {}) {
102
156
  },
103
157
  async stop() {
104
158
  stopped = true;
159
+ if (reconnectTimer) {
160
+ clearTimeout(reconnectTimer);
161
+ reconnectTimer = null;
162
+ }
105
163
  if (socket) {
106
- socket.end(undefined);
164
+ closeWhatsAppSocket(socket);
107
165
  socket = null;
108
166
  }
109
167
  },
110
168
  async sendText(peerId, text) {
111
169
  if (!socket)
112
170
  throw new Error("WhatsApp socket not initialized");
113
- await socket.sendMessage(peerId, { text });
171
+ const sent = await socket.sendMessage(peerId, { text });
172
+ recordSentMessage(sent?.key?.id);
114
173
  },
115
174
  };
116
175
  }
117
- export async function loginWhatsApp(config, logger) {
176
+ export async function loginWhatsApp(config, logger, options = {}) {
118
177
  const authDir = path.resolve(config.whatsappAuthDir);
119
- ensureDir(authDir);
120
178
  const log = logger.child({ channel: "whatsapp" });
121
- const { state, saveCreds } = await useMultiFileAuthState(authDir);
122
- const { version } = await fetchLatestBaileysVersion();
123
- const credsPath = path.join(authDir, "creds.json");
124
- const maxAttempts = 2;
125
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
126
- const result = await new Promise((resolve) => {
127
- let finished = false;
128
- const sock = makeWASocket({
129
- auth: {
130
- creds: state.creds,
131
- keys: makeCacheableSignalKeyStore(state.keys, log),
132
- },
133
- version,
134
- logger: log,
135
- printQRInTerminal: false,
136
- syncFullHistory: false,
137
- markOnlineOnConnect: false,
138
- browser: ["owpenbot", "cli", "0.1.0"],
139
- });
140
- const finish = (reason, status) => {
141
- if (finished)
142
- return;
143
- finished = true;
144
- log.info({ reason }, "whatsapp login finished");
145
- sock.end(undefined);
146
- resolve(status);
147
- };
148
- sock.ev.on("creds.update", async () => {
149
- await saveCreds();
150
- if (state.creds?.registered) {
151
- finish("creds.registered", "linked");
152
- }
153
- });
154
- sock.ev.on("connection.update", (update) => {
155
- if (update.qr) {
156
- qrcode.generate(update.qr, { small: true });
157
- log.info("scan the QR code to connect WhatsApp");
158
- }
159
- if (update.connection === "open") {
160
- finish("connection.open", "linked");
161
- }
162
- if (update.connection === "close") {
163
- const lastDisconnect = update.lastDisconnect;
164
- const statusCode = lastDisconnect?.error?.output?.statusCode;
165
- if (statusCode === 515) {
166
- if (state.creds?.registered || fs.existsSync(credsPath)) {
167
- log.info("whatsapp login requires reconnect; completing login");
168
- finish("connection.restart.required", "linked");
169
- }
170
- else {
171
- log.warn("whatsapp restart requested before creds registered");
172
- finish("connection.restart.required", "restart");
173
- }
174
- return;
175
- }
176
- if (state.creds?.registered) {
177
- finish("connection.close.registered", "linked");
178
- }
179
- }
180
- });
179
+ const timeoutMs = options.timeoutMs ?? 120_000;
180
+ const attemptLogin = async (phase, printQr) => {
181
+ const sock = await createWhatsAppSocket({
182
+ authDir,
183
+ logger: log,
184
+ printQr,
185
+ onStatus: options.onStatus,
181
186
  });
182
- if (result === "linked") {
183
- return;
187
+ try {
188
+ if (phase === "initial") {
189
+ options.onStatus?.("Waiting for WhatsApp scan...");
190
+ }
191
+ else {
192
+ options.onStatus?.("Reconnecting WhatsApp session...");
193
+ }
194
+ await waitForWhatsAppConnection(sock, { timeoutMs });
195
+ options.onStatus?.("WhatsApp linked.");
196
+ return { ok: true };
197
+ }
198
+ catch (error) {
199
+ const statusCode = getStatusCode(error);
200
+ return { ok: false, error, statusCode };
184
201
  }
185
- if (attempt < maxAttempts) {
186
- log.warn("retrying whatsapp login after restart");
187
- await new Promise((resolve) => setTimeout(resolve, 1500));
202
+ finally {
203
+ setTimeout(() => closeWhatsAppSocket(sock), 500);
204
+ }
205
+ };
206
+ const initial = await attemptLogin("initial", true);
207
+ if (initial.ok)
208
+ return;
209
+ if (initial.statusCode === DisconnectReason.loggedOut) {
210
+ options.onStatus?.("WhatsApp logged out. Run: owpenwork whatsapp login.");
211
+ throw new Error("WhatsApp logged out");
212
+ }
213
+ const shouldRetry = initial.statusCode === 515 || (initial.statusCode === undefined && hasWhatsAppCreds(authDir));
214
+ if (shouldRetry) {
215
+ options.onStatus?.("WhatsApp asked for a restart; retrying connection...");
216
+ const retry = await attemptLogin("restart", false);
217
+ if (retry.ok)
218
+ return;
219
+ if (retry.statusCode === DisconnectReason.loggedOut) {
220
+ options.onStatus?.("WhatsApp logged out. Run: owpenwork whatsapp login.");
188
221
  }
222
+ throw new Error(`WhatsApp login failed after restart: ${String(retry.error)}`);
223
+ }
224
+ if (!initial.statusCode && !hasWhatsAppCreds(authDir)) {
225
+ options.onStatus?.("Timed out waiting for QR scan. Run login again for a fresh QR.");
189
226
  }
190
- throw new Error("WhatsApp login failed after restart");
227
+ throw new Error(`WhatsApp login failed: ${String(initial.error)}`);
191
228
  }
192
229
  export function unpairWhatsApp(config, logger) {
193
230
  const authDir = path.resolve(config.whatsappAuthDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "owpenwork",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "WhatsApp bridge for a running OpenCode server",
5
5
  "private": false,
6
6
  "type": "module",