beecork 1.4.11 → 1.6.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.
Files changed (138) hide show
  1. package/dist/capabilities/index.d.ts +1 -1
  2. package/dist/capabilities/index.js +1 -1
  3. package/dist/capabilities/manager.js +13 -9
  4. package/dist/capabilities/packs.js +3 -1
  5. package/dist/channels/admin.d.ts +10 -0
  6. package/dist/channels/admin.js +20 -0
  7. package/dist/channels/command-handler.d.ts +2 -10
  8. package/dist/channels/command-handler.js +90 -84
  9. package/dist/channels/discord.d.ts +4 -9
  10. package/dist/channels/discord.js +59 -42
  11. package/dist/channels/index.d.ts +1 -1
  12. package/dist/channels/loader.js +13 -4
  13. package/dist/channels/pipeline.js +14 -5
  14. package/dist/channels/registry.d.ts +17 -1
  15. package/dist/channels/registry.js +33 -4
  16. package/dist/channels/send-helpers.d.ts +19 -0
  17. package/dist/channels/send-helpers.js +21 -0
  18. package/dist/channels/telegram.d.ts +21 -14
  19. package/dist/channels/telegram.js +214 -104
  20. package/dist/channels/types.d.ts +13 -38
  21. package/dist/channels/voice-state.d.ts +29 -0
  22. package/dist/channels/voice-state.js +45 -0
  23. package/dist/channels/webhook.d.ts +2 -5
  24. package/dist/channels/webhook.js +88 -29
  25. package/dist/channels/whatsapp.d.ts +9 -7
  26. package/dist/channels/whatsapp.js +141 -100
  27. package/dist/cli/capabilities.js +4 -4
  28. package/dist/cli/channel.js +16 -6
  29. package/dist/cli/commands.js +12 -9
  30. package/dist/cli/doctor.js +85 -27
  31. package/dist/cli/handoff.d.ts +7 -14
  32. package/dist/cli/handoff.js +9 -44
  33. package/dist/cli/mcp.js +5 -5
  34. package/dist/cli/media.js +21 -8
  35. package/dist/cli/setup.js +9 -8
  36. package/dist/cli/store.js +29 -12
  37. package/dist/config.d.ts +5 -1
  38. package/dist/config.js +20 -22
  39. package/dist/daemon.js +113 -51
  40. package/dist/dashboard/html.js +100 -20
  41. package/dist/dashboard/routes.d.ts +17 -0
  42. package/dist/dashboard/routes.js +623 -0
  43. package/dist/dashboard/server.js +38 -489
  44. package/dist/db/connection.d.ts +29 -0
  45. package/dist/db/connection.js +37 -0
  46. package/dist/db/index.js +43 -11
  47. package/dist/db/migrations.js +114 -22
  48. package/dist/delegation/manager.js +10 -4
  49. package/dist/index.js +39 -59
  50. package/dist/knowledge/manager.js +26 -12
  51. package/dist/mcp/handlers.d.ts +37 -0
  52. package/dist/mcp/handlers.js +520 -0
  53. package/dist/mcp/server.js +44 -858
  54. package/dist/mcp/tool-definitions.d.ts +1225 -0
  55. package/dist/mcp/tool-definitions.js +412 -0
  56. package/dist/mcp/validate.d.ts +23 -0
  57. package/dist/mcp/validate.js +65 -0
  58. package/dist/media/factory.js +18 -14
  59. package/dist/media/generators/dall-e.js +2 -2
  60. package/dist/media/generators/kling.js +4 -4
  61. package/dist/media/generators/lyria.js +1 -1
  62. package/dist/media/generators/nano-banana.d.ts +1 -1
  63. package/dist/media/generators/nano-banana.js +2 -2
  64. package/dist/media/generators/poll-util.js +4 -4
  65. package/dist/media/generators/recraft.js +3 -3
  66. package/dist/media/generators/runway.js +4 -4
  67. package/dist/media/generators/stable-diffusion.js +2 -2
  68. package/dist/media/generators/veo.js +1 -1
  69. package/dist/media/index.d.ts +2 -7
  70. package/dist/media/index.js +2 -2
  71. package/dist/media/store.d.ts +7 -0
  72. package/dist/media/store.js +18 -4
  73. package/dist/media/types.d.ts +22 -0
  74. package/dist/notifications/index.d.ts +2 -4
  75. package/dist/notifications/index.js +6 -19
  76. package/dist/notifications/ntfy.js +3 -3
  77. package/dist/observability/analytics.d.ts +1 -1
  78. package/dist/observability/analytics.js +41 -16
  79. package/dist/projects/index.d.ts +3 -2
  80. package/dist/projects/index.js +2 -2
  81. package/dist/projects/manager.d.ts +1 -7
  82. package/dist/projects/manager.js +66 -42
  83. package/dist/projects/router.d.ts +12 -0
  84. package/dist/projects/router.js +98 -45
  85. package/dist/service/install.js +15 -5
  86. package/dist/service/windows.js +1 -1
  87. package/dist/session/budget-guard.d.ts +20 -0
  88. package/dist/session/budget-guard.js +31 -0
  89. package/dist/session/circuit-breaker.d.ts +5 -3
  90. package/dist/session/circuit-breaker.js +45 -20
  91. package/dist/session/context-compactor.d.ts +32 -0
  92. package/dist/session/context-compactor.js +45 -0
  93. package/dist/session/context-monitor.js +2 -2
  94. package/dist/session/handoff.d.ts +21 -0
  95. package/dist/session/handoff.js +50 -0
  96. package/dist/session/manager.d.ts +21 -5
  97. package/dist/session/manager.js +166 -153
  98. package/dist/session/memory-store.d.ts +29 -0
  99. package/dist/session/memory-store.js +45 -0
  100. package/dist/session/message-queue.d.ts +28 -0
  101. package/dist/session/message-queue.js +52 -0
  102. package/dist/session/pending-dispatcher.d.ts +31 -0
  103. package/dist/session/pending-dispatcher.js +120 -0
  104. package/dist/session/pending-store.d.ts +60 -0
  105. package/dist/session/pending-store.js +118 -0
  106. package/dist/session/stale-session.d.ts +31 -0
  107. package/dist/session/stale-session.js +45 -0
  108. package/dist/session/subprocess.d.ts +3 -0
  109. package/dist/session/subprocess.js +54 -11
  110. package/dist/session/tab-store.d.ts +28 -0
  111. package/dist/session/tab-store.js +78 -0
  112. package/dist/tasks/scheduler.d.ts +13 -0
  113. package/dist/tasks/scheduler.js +97 -18
  114. package/dist/tasks/store.js +26 -12
  115. package/dist/timeline/logger.js +3 -1
  116. package/dist/timeline/query.js +15 -5
  117. package/dist/types.d.ts +49 -9
  118. package/dist/util/auto-heal.js +15 -5
  119. package/dist/util/install-info.js +3 -1
  120. package/dist/util/logger.d.ts +1 -1
  121. package/dist/util/logger.js +63 -24
  122. package/dist/util/paths.d.ts +2 -0
  123. package/dist/util/paths.js +16 -3
  124. package/dist/util/rate-limiter.js +8 -0
  125. package/dist/util/retry.js +1 -1
  126. package/dist/util/text.d.ts +21 -1
  127. package/dist/util/text.js +38 -8
  128. package/dist/voice/index.js +5 -1
  129. package/dist/voice/stt.js +14 -6
  130. package/dist/voice/tts.js +1 -1
  131. package/dist/watchers/scheduler.js +11 -5
  132. package/package.json +6 -1
  133. package/dist/session/tool-classifier.d.ts +0 -4
  134. package/dist/session/tool-classifier.js +0 -56
  135. package/dist/users/index.d.ts +0 -2
  136. package/dist/users/index.js +0 -1
  137. package/dist/users/service.d.ts +0 -17
  138. package/dist/users/service.js +0 -46
@@ -0,0 +1,45 @@
1
+ import { initVoiceProviders } from '../voice/index.js';
2
+ /**
3
+ * Per-channel STT/TTS state. Used to be duplicated across Telegram, WhatsApp,
4
+ * and Discord; consolidated here so init + warmup + transcription happen the
5
+ * same way across all 3.
6
+ *
7
+ * Discord historically only called warmup() and did NOT transcribe — preserve
8
+ * that behavior unless the caller explicitly asks for transcription.
9
+ */
10
+ export class VoiceState {
11
+ channelId;
12
+ stt = null;
13
+ tts = null;
14
+ warmedUp = false;
15
+ constructor(channelId) {
16
+ this.channelId = channelId;
17
+ }
18
+ init(config) {
19
+ const { stt, tts } = initVoiceProviders(config.voice);
20
+ this.stt = stt;
21
+ this.tts = tts;
22
+ }
23
+ /** One-shot warmup (no media). Used by Discord today. */
24
+ async warmup() {
25
+ if (this.warmedUp || !this.stt)
26
+ return;
27
+ try {
28
+ this.stt.warmup?.();
29
+ }
30
+ catch {
31
+ /* warmup is best-effort */
32
+ }
33
+ this.warmedUp = true;
34
+ }
35
+ /**
36
+ * Transcribe voice attachments in-place (mutates the caption fields).
37
+ * Returns the updated warmed-up flag. No-op if STT isn't configured.
38
+ */
39
+ async transcribe(media) {
40
+ if (!this.stt)
41
+ return;
42
+ const { transcribeVoiceMessages } = await import('../voice/index.js');
43
+ this.warmedUp = await transcribeVoiceMessages(media, this.stt, this.channelId, this.warmedUp);
44
+ }
45
+ }
@@ -1,16 +1,13 @@
1
- import type { Channel, ChannelContext, InboundMessageHandler, SendOptions } from './types.js';
1
+ import type { Channel, ChannelContext, SendOptions } from './types.js';
2
2
  export declare class WebhookChannel implements Channel {
3
3
  readonly id = "webhook";
4
4
  readonly name = "Webhook";
5
- readonly maxMessageLength = 100000;
6
- readonly supportsStreaming = false;
7
- readonly supportsMedia = false;
5
+ readonly maxMessageLength: 100000;
8
6
  private server;
9
7
  private ctx;
10
8
  constructor(ctx: ChannelContext);
11
9
  start(): Promise<void>;
12
10
  stop(): void;
13
- onMessage(_handler: InboundMessageHandler): void;
14
11
  sendMessage(_peerId: string, _text: string, _options?: SendOptions): Promise<void>;
15
12
  sendNotification(_message: string, _urgent?: boolean): Promise<void>;
16
13
  setTyping(_peerId: string, _active: boolean): Promise<void>;
@@ -1,13 +1,26 @@
1
1
  import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
3
  import { logger } from '../util/logger.js';
4
- import { validateTabName } from '../config.js';
4
+ import { validateTabNameOrDefault } from '../config.js';
5
+ import { inboundLimiter } from '../util/rate-limiter.js';
6
+ import { MESSAGE_LIMITS } from '../util/text.js';
7
+ import { processInboundMessage } from './pipeline.js';
8
+ function safeEqualString(a, b) {
9
+ const ab = Buffer.from(a);
10
+ const bb = Buffer.from(b);
11
+ if (ab.length !== bb.length)
12
+ return false;
13
+ try {
14
+ return crypto.timingSafeEqual(ab, bb);
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
5
20
  export class WebhookChannel {
6
21
  id = 'webhook';
7
22
  name = 'Webhook';
8
- maxMessageLength = 100000; // Webhooks can handle large payloads
9
- supportsStreaming = false;
10
- supportsMedia = false;
23
+ maxMessageLength = MESSAGE_LIMITS.WEBHOOK_PROMPT;
11
24
  server = null;
12
25
  ctx;
13
26
  constructor(ctx) {
@@ -17,6 +30,16 @@ export class WebhookChannel {
17
30
  const config = this.getConfig();
18
31
  if (!config?.enabled)
19
32
  return;
33
+ // Fail-secure: a webhook running with no auth turns localhost-injected
34
+ // prompts (from any local process or any user on a shared host) into
35
+ // arbitrary claude --dangerously-skip-permissions runs. Require either
36
+ // an authToken or hmacSecret, OR an explicit allowUnauthLocalhost opt-in.
37
+ if (!config.authToken && !config.hmacSecret && !config.allowUnauthLocalhost) {
38
+ logger.error('Webhook channel refusing to start: no authToken or hmacSecret configured. ' +
39
+ 'Set one in ~/.beecork/config.json under webhook.authToken/hmacSecret, or ' +
40
+ 'explicitly opt in with webhook.allowUnauthLocalhost=true (NOT recommended on shared hosts).');
41
+ return;
42
+ }
20
43
  const port = config.port || 8374;
21
44
  this.server = http.createServer(async (req, res) => {
22
45
  // CORS headers for API clients
@@ -40,22 +63,21 @@ export class WebhookChannel {
40
63
  return;
41
64
  }
42
65
  const tabName = decodeURIComponent(match[1]);
43
- // Validate tab name
44
- if (tabName !== 'default') {
45
- const tabError = validateTabName(tabName);
46
- if (tabError) {
47
- res.writeHead(400);
48
- res.end(JSON.stringify({ error: tabError }));
49
- return;
50
- }
66
+ // Validate tab name (allow "default" — it's a reference, not a creation)
67
+ const tabError = validateTabNameOrDefault(tabName);
68
+ if (tabError) {
69
+ res.writeHead(400);
70
+ res.end(JSON.stringify({ error: tabError }));
71
+ return;
51
72
  }
52
73
  // Read body first (needed for both JSON parsing and HMAC verification)
53
74
  let body = '';
54
75
  for await (const chunk of req) {
55
76
  body += chunk;
56
- if (body.length > 1024 * 1024) { // 1MB limit
77
+ if (body.length > MESSAGE_LIMITS.HTTP_BODY) {
57
78
  res.writeHead(413);
58
79
  res.end(JSON.stringify({ error: 'Payload too large' }));
80
+ req.destroy();
59
81
  return;
60
82
  }
61
83
  }
@@ -65,6 +87,12 @@ export class WebhookChannel {
65
87
  res.end(JSON.stringify({ error: 'Unauthorized' }));
66
88
  return;
67
89
  }
90
+ // Rate-limit AFTER auth so unauthenticated callers don't burn the budget
91
+ if (!inboundLimiter.check(this.id)) {
92
+ res.writeHead(429);
93
+ res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
94
+ return;
95
+ }
68
96
  let payload;
69
97
  try {
70
98
  payload = JSON.parse(body);
@@ -81,23 +109,57 @@ export class WebhookChannel {
81
109
  return;
82
110
  }
83
111
  const isSync = payload.sync ?? false;
112
+ const remote = req.socket.remoteAddress ?? 'webhook';
84
113
  try {
85
114
  if (isSync) {
86
- // Sync mode: wait for Claude response
87
- const result = await this.ctx.tabManager.sendMessage(tabName, prompt);
88
- res.writeHead(result.error ? 500 : 200);
115
+ // Sync mode: route through the shared pipeline so routing/enrichment apply.
116
+ const result = await processInboundMessage({
117
+ text: prompt,
118
+ media: [],
119
+ channelId: this.id,
120
+ tabManager: this.ctx.tabManager,
121
+ userId: remote,
122
+ sendProgress: () => {
123
+ /* webhook has no progress channel */
124
+ },
125
+ overrideTabName: tabName,
126
+ });
127
+ res.writeHead(result.isError ? 500 : 200);
89
128
  res.end(JSON.stringify({
90
- text: result.text,
91
- tab: tabName,
92
- costUsd: result.costUsd,
93
- durationMs: result.durationMs,
94
- error: result.error,
129
+ text: result.responseText,
130
+ tab: result.tabName,
131
+ error: result.isError ? result.responseText : undefined,
95
132
  }));
96
133
  }
97
134
  else {
98
- // Async mode: accept and process in background
99
- this.ctx.tabManager.sendMessage(tabName, prompt).catch(err => {
135
+ // Async mode: fire-and-forget through the pipeline.
136
+ // Surface failures to the user via broadcastNotify since the HTTP response
137
+ // is already 202 and the caller has no other way to learn.
138
+ processInboundMessage({
139
+ text: prompt,
140
+ media: [],
141
+ channelId: this.id,
142
+ tabManager: this.ctx.tabManager,
143
+ userId: remote,
144
+ sendProgress: () => {
145
+ /* webhook has no progress channel */
146
+ },
147
+ overrideTabName: tabName,
148
+ })
149
+ .then((result) => {
150
+ if (result.isError && this.ctx.notifyCallback) {
151
+ this.ctx
152
+ .notifyCallback(`Webhook async failed for "${tabName}": ${result.responseText}`)
153
+ .catch(() => { });
154
+ }
155
+ })
156
+ .catch((err) => {
100
157
  logger.error(`Webhook async processing failed for tab ${tabName}:`, err);
158
+ if (this.ctx.notifyCallback) {
159
+ this.ctx
160
+ .notifyCallback(`Webhook async failed for "${tabName}": ${err instanceof Error ? err.message : String(err)}`)
161
+ .catch(() => { });
162
+ }
101
163
  });
102
164
  res.writeHead(202);
103
165
  res.end(JSON.stringify({ accepted: true, tab: tabName }));
@@ -120,9 +182,6 @@ export class WebhookChannel {
120
182
  }
121
183
  logger.info('Webhook channel stopped');
122
184
  }
123
- onMessage(_handler) {
124
- // Webhooks handle messages directly in the HTTP handler
125
- }
126
185
  async sendMessage(_peerId, _text, _options) {
127
186
  // Webhooks are request-response — responses are sent in the HTTP handler
128
187
  }
@@ -136,10 +195,10 @@ export class WebhookChannel {
136
195
  // No auth configured = allow all (localhost only)
137
196
  if (!config.authToken && !config.hmacSecret)
138
197
  return true;
139
- // Bearer token auth
198
+ // Bearer token auth (constant-time compare)
140
199
  if (config.authToken) {
141
- const authHeader = req.headers.authorization;
142
- if (authHeader === `Bearer ${config.authToken}`)
200
+ const authHeader = req.headers.authorization || '';
201
+ if (safeEqualString(authHeader, `Bearer ${config.authToken}`))
143
202
  return true;
144
203
  }
145
204
  // HMAC signature auth (for GitHub-style webhooks)
@@ -1,26 +1,28 @@
1
- import type { Channel, ChannelContext, InboundMessageHandler, SendOptions } from './types.js';
1
+ import type { Channel, ChannelContext, SendOptions } from './types.js';
2
2
  export declare class WhatsAppChannel implements Channel {
3
3
  readonly id = "whatsapp";
4
4
  readonly name = "WhatsApp";
5
5
  readonly maxMessageLength = 8192;
6
- readonly supportsStreaming = false;
7
- readonly supportsMedia = true;
8
6
  private sock;
9
7
  private ctx;
10
8
  private allowedNumbers;
11
9
  private reconnectAttempts;
12
10
  private readonly maxReconnectAttempts;
13
11
  private readonly backoffDelays;
14
- private sttProvider;
15
- private ttsProvider;
16
- private sttWarmedUp;
12
+ private voice;
17
13
  constructor(ctx: ChannelContext);
14
+ /**
15
+ * Schedule the next reconnect with exponential backoff. Unlike the previous
16
+ * inline setTimeout, this path retries when `start()` itself rejects (auth
17
+ * failure, baileys init throw, etc.) instead of going permanently silent
18
+ * after a single failed attempt.
19
+ */
20
+ private scheduleReconnect;
18
21
  start(): Promise<void>;
19
22
  stop(): void;
20
23
  sendMessage(peerId: string, text: string, _options?: SendOptions): Promise<void>;
21
24
  sendNotification(message: string, _urgent?: boolean): Promise<void>;
22
25
  setTyping(peerId: string, active: boolean): Promise<void>;
23
- onMessage(_handler: InboundMessageHandler): void;
24
26
  private sendResponse;
25
27
  private isAllowed;
26
28
  }
@@ -1,39 +1,76 @@
1
+ /*
2
+ * WhatsApp integration via @whiskeysockets/baileys.
3
+ *
4
+ * baileys is a peer-optional dependency loaded by dynamic import, and its
5
+ * runtime types are intentionally loose (the lib uses heavy union/index
6
+ * types that don't survive serialization across the dynamic-import boundary).
7
+ * Trying to type every variant of Baileys' message/connection shapes would
8
+ * either pull baileys into the static graph (bloating non-WhatsApp installs)
9
+ * or require maintaining a parallel shim. We accept `any` at this trust
10
+ * boundary; runtime validation lives in the descriptors table inside
11
+ * messages.upsert.
12
+ */
13
+ /* eslint-disable @typescript-eslint/no-explicit-any */
1
14
  import fs from 'node:fs';
2
15
  import { logger } from '../util/logger.js';
3
16
  import { saveMedia, isOversized } from '../media/store.js';
4
- import { retryWithBackoff } from '../util/retry.js';
5
- import { chunkText } from '../util/text.js';
17
+ import { sendChunkedResponse } from './send-helpers.js';
6
18
  import { inboundLimiter } from '../util/rate-limiter.js';
7
19
  import { processInboundMessage } from './pipeline.js';
8
- import { initVoiceProviders } from '../voice/index.js';
20
+ import { isChannelAdmin } from './admin.js';
21
+ import { VoiceState } from './voice-state.js';
9
22
  const WHATSAPP_MAX_LENGTH = 8192;
10
23
  export class WhatsAppChannel {
11
24
  id = 'whatsapp';
12
25
  name = 'WhatsApp';
13
26
  maxMessageLength = WHATSAPP_MAX_LENGTH;
14
- supportsStreaming = false;
15
- supportsMedia = true;
16
27
  sock = null;
17
28
  ctx;
18
29
  allowedNumbers;
19
30
  reconnectAttempts = 0;
20
31
  maxReconnectAttempts = 10;
21
32
  backoffDelays = [1000, 5000, 15000, 30000, 60000];
22
- sttProvider = null;
23
- ttsProvider = null;
24
- sttWarmedUp = false;
33
+ voice = new VoiceState('whatsapp');
25
34
  constructor(ctx) {
26
35
  this.ctx = ctx;
27
36
  this.allowedNumbers = new Set(ctx.config.whatsapp?.allowedNumbers ?? []);
28
37
  }
38
+ /**
39
+ * Schedule the next reconnect with exponential backoff. Unlike the previous
40
+ * inline setTimeout, this path retries when `start()` itself rejects (auth
41
+ * failure, baileys init throw, etc.) instead of going permanently silent
42
+ * after a single failed attempt.
43
+ */
44
+ scheduleReconnect() {
45
+ this.reconnectAttempts++;
46
+ if (this.reconnectAttempts > this.maxReconnectAttempts) {
47
+ logger.error(`WhatsApp reconnect failed after ${this.maxReconnectAttempts} attempts, giving up`);
48
+ this.ctx
49
+ .notifyCallback?.('⚠️ WhatsApp disconnected after 10 reconnection attempts. Restart daemon to reconnect.')
50
+ .catch((err) => logger.error('Failed to send WhatsApp disconnect notification:', err));
51
+ return;
52
+ }
53
+ const delayIdx = Math.min(this.reconnectAttempts - 1, this.backoffDelays.length - 1);
54
+ const delay = this.backoffDelays[delayIdx];
55
+ logger.warn(`WhatsApp connection closed, reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
56
+ setTimeout(() => {
57
+ this.start().catch((err) => {
58
+ logger.error('WhatsApp reconnect attempt failed:', err);
59
+ // Recurse via scheduleReconnect so backoff keeps escalating instead of
60
+ // silently dropping the reconnect chain after one failed start().
61
+ this.scheduleReconnect();
62
+ });
63
+ }, delay);
64
+ }
29
65
  async start() {
30
- // Initialize voice providers
31
- const { stt, tts } = initVoiceProviders(this.ctx.config.voice);
32
- this.sttProvider = stt;
33
- this.ttsProvider = tts;
66
+ // Initialize voice providers (STT + TTS)
67
+ this.voice.init(this.ctx.config);
34
68
  try {
35
- const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, downloadMediaMessage, fetchLatestBaileysVersion } = await import('@whiskeysockets/baileys');
36
- const sessionPath = this.ctx.config.whatsapp?.sessionPath ?? `${process.env.HOME}/.beecork/whatsapp-session`;
69
+ const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, downloadMediaMessage, fetchLatestBaileysVersion, } = await import('@whiskeysockets/baileys');
70
+ const { getWhatsappSessionPath } = await import('../util/paths.js');
71
+ // Use the centralized path helper — the previous fallback hard-coded
72
+ // process.env.HOME which bypassed BEECORK_HOME for tests/isolation.
73
+ const sessionPath = this.ctx.config.whatsapp?.sessionPath ?? getWhatsappSessionPath();
37
74
  fs.mkdirSync(sessionPath, { recursive: true, mode: 0o700 });
38
75
  const { state, saveCreds } = await useMultiFileAuthState(sessionPath);
39
76
  const { version } = await fetchLatestBaileysVersion().catch(() => ({ version: undefined }));
@@ -60,21 +97,7 @@ export class WhatsAppChannel {
60
97
  if (connection === 'close') {
61
98
  const reason = lastDisconnect?.error?.output?.statusCode;
62
99
  if (reason !== DisconnectReason.loggedOut) {
63
- this.reconnectAttempts++;
64
- if (this.reconnectAttempts > this.maxReconnectAttempts) {
65
- logger.error(`WhatsApp reconnect failed after ${this.maxReconnectAttempts} attempts, giving up`);
66
- this.ctx.notifyCallback?.('⚠️ WhatsApp disconnected after 10 reconnection attempts. Restart daemon to reconnect.')
67
- .catch(err => logger.error('Failed to send WhatsApp disconnect notification:', err));
68
- return;
69
- }
70
- const delayIdx = Math.min(this.reconnectAttempts - 1, this.backoffDelays.length - 1);
71
- const delay = this.backoffDelays[delayIdx];
72
- logger.warn(`WhatsApp connection closed, reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
73
- setTimeout(() => {
74
- this.start().catch(err => {
75
- logger.error('WhatsApp reconnect failed:', err);
76
- });
77
- }, delay);
100
+ this.scheduleReconnect();
78
101
  }
79
102
  else {
80
103
  logger.error('WhatsApp logged out. Please re-scan QR code.');
@@ -94,75 +117,84 @@ export class WhatsAppChannel {
94
117
  return;
95
118
  // Rate limit check
96
119
  if (!inboundLimiter.check(this.id)) {
97
- await sock.sendMessage(sender, { text: "I'm receiving too many messages right now. Please wait a moment." }).catch(() => { });
120
+ await sock
121
+ .sendMessage(sender, {
122
+ text: "I'm receiving too many messages right now. Please wait a moment.",
123
+ })
124
+ .catch(() => { });
98
125
  return;
99
126
  }
100
127
  const text = msg.message.conversation ||
101
128
  msg.message.extendedTextMessage?.text ||
102
129
  msg.message.imageMessage?.caption ||
103
- msg.message.videoMessage?.caption || '';
104
- // Download media (in parallel)
105
- const waDownloadTasks = [];
106
- if (msg.message.imageMessage) {
107
- waDownloadTasks.push(downloadMediaMessage(msg, 'buffer', {})
108
- .then((buffer) => {
109
- if (buffer && !isOversized(buffer.length)) {
110
- const filePath = saveMedia(buffer, 'jpg');
111
- return { type: 'image', mimeType: msg.message.imageMessage.mimetype || 'image/jpeg', filePath };
112
- }
113
- return null;
114
- })
115
- .catch(() => null));
116
- }
117
- if (msg.message.audioMessage) {
118
- waDownloadTasks.push(downloadMediaMessage(msg, 'buffer', {})
119
- .then((buffer) => {
120
- if (buffer && !isOversized(buffer.length)) {
121
- const ext = msg.message.audioMessage.ptt ? 'ogg' : 'mp3';
122
- const filePath = saveMedia(buffer, ext);
130
+ msg.message.videoMessage?.caption ||
131
+ '';
132
+ const descriptors = [
133
+ {
134
+ key: 'imageMessage',
135
+ build: (m, buf) => ({
136
+ type: 'image',
137
+ mimeType: m.imageMessage.mimetype || 'image/jpeg',
138
+ filePath: saveMedia(buf, 'jpg'),
139
+ }),
140
+ },
141
+ {
142
+ key: 'audioMessage',
143
+ build: (m, buf) => {
144
+ const ext = m.audioMessage.ptt ? 'ogg' : 'mp3';
123
145
  return {
124
- type: (msg.message.audioMessage.ptt ? 'voice' : 'audio'),
125
- mimeType: msg.message.audioMessage.mimetype || 'audio/ogg',
126
- filePath,
127
- duration: msg.message.audioMessage.seconds ?? undefined,
146
+ type: m.audioMessage.ptt ? 'voice' : 'audio',
147
+ mimeType: m.audioMessage.mimetype || 'audio/ogg',
148
+ filePath: saveMedia(buf, ext),
149
+ duration: m.audioMessage.seconds ?? undefined,
128
150
  };
129
- }
130
- return null;
131
- })
132
- .catch(() => null));
133
- }
134
- if (msg.message.documentMessage) {
151
+ },
152
+ },
153
+ {
154
+ key: 'documentMessage',
155
+ build: (m, buf) => {
156
+ const ext = m.documentMessage.fileName?.split('.').pop() || 'bin';
157
+ return {
158
+ type: 'document',
159
+ mimeType: m.documentMessage.mimetype || 'application/octet-stream',
160
+ filePath: saveMedia(buf, ext, m.documentMessage.fileName ?? undefined),
161
+ fileName: m.documentMessage.fileName ?? undefined,
162
+ };
163
+ },
164
+ },
165
+ {
166
+ key: 'videoMessage',
167
+ build: (m, buf) => ({
168
+ type: 'video',
169
+ mimeType: m.videoMessage.mimetype || 'video/mp4',
170
+ filePath: saveMedia(buf, 'mp4'),
171
+ duration: m.videoMessage.seconds ?? undefined,
172
+ }),
173
+ },
174
+ ];
175
+ const waDownloadTasks = [];
176
+ for (const d of descriptors) {
177
+ if (!msg.message[d.key])
178
+ continue;
135
179
  waDownloadTasks.push(downloadMediaMessage(msg, 'buffer', {})
136
180
  .then((buffer) => {
137
- if (buffer && !isOversized(buffer.length)) {
138
- const ext = msg.message.documentMessage.fileName?.split('.').pop() || 'bin';
139
- const filePath = saveMedia(buffer, ext, msg.message.documentMessage.fileName ?? undefined);
140
- return { type: 'document', mimeType: msg.message.documentMessage.mimetype || 'application/octet-stream', filePath, fileName: msg.message.documentMessage.fileName ?? undefined };
181
+ if (!buffer || isOversized(buffer.length))
182
+ return null;
183
+ try {
184
+ return d.build(msg.message, buffer);
141
185
  }
142
- return null;
143
- })
144
- .catch(() => null));
145
- }
146
- if (msg.message.videoMessage) {
147
- waDownloadTasks.push(downloadMediaMessage(msg, 'buffer', {})
148
- .then((buffer) => {
149
- if (buffer && !isOversized(buffer.length)) {
150
- const filePath = saveMedia(buffer, 'mp4');
151
- return { type: 'video', mimeType: msg.message.videoMessage.mimetype || 'video/mp4', filePath, duration: msg.message.videoMessage.seconds ?? undefined };
186
+ catch {
187
+ return null;
152
188
  }
153
- return null;
154
189
  })
155
190
  .catch(() => null));
156
191
  }
157
192
  const waResults = await Promise.allSettled(waDownloadTasks);
158
193
  const media = waResults
159
194
  .filter((r) => r.status === 'fulfilled' && r.value !== null)
160
- .map(r => r.value);
195
+ .map((r) => r.value);
161
196
  // Transcribe voice messages if STT is configured
162
- if (this.sttProvider) {
163
- const { transcribeVoiceMessages } = await import('../voice/index.js');
164
- this.sttWarmedUp = await transcribeVoiceMessages(media, this.sttProvider, 'whatsapp', this.sttWarmedUp);
165
- }
197
+ await this.voice.transcribe(media);
166
198
  if (!text && media.length === 0)
167
199
  return;
168
200
  try {
@@ -173,7 +205,7 @@ export class WhatsAppChannel {
173
205
  const cmdResult = await handleSharedCommand({
174
206
  userId: waUserId,
175
207
  text,
176
- isAdmin: this.allowedNumbers.size > 0 && waUserId === [...this.allowedNumbers][0],
208
+ isAdmin: isChannelAdmin(this.allowedNumbers, waUserId, this.ctx.config.whatsapp?.adminNumber),
177
209
  channelId: 'whatsapp',
178
210
  }, this.ctx.tabManager);
179
211
  if (cmdResult.handled) {
@@ -190,7 +222,7 @@ export class WhatsAppChannel {
190
222
  channelId: 'whatsapp',
191
223
  tabManager: this.ctx.tabManager,
192
224
  voiceReplyMode: this.ctx.config.voice?.replyMode,
193
- ttsProvider: this.ttsProvider,
225
+ ttsProvider: this.voice.tts,
194
226
  userId: waUserId,
195
227
  sendProgress: (msg) => {
196
228
  sock.sendMessage(sender, { text: msg }).catch(() => { });
@@ -202,7 +234,11 @@ export class WhatsAppChannel {
202
234
  return;
203
235
  // Send voice reply if TTS generated audio
204
236
  if (pipelineResult.audioPath) {
205
- await sock.sendMessage(sender, { audio: { url: pipelineResult.audioPath }, mimetype: 'audio/ogg; codecs=opus', ptt: true });
237
+ await sock.sendMessage(sender, {
238
+ audio: { url: pipelineResult.audioPath },
239
+ mimetype: 'audio/ogg; codecs=opus',
240
+ ptt: true,
241
+ });
206
242
  if (pipelineResult.voiceOnly)
207
243
  return;
208
244
  }
@@ -210,7 +246,11 @@ export class WhatsAppChannel {
210
246
  }
211
247
  catch (err) {
212
248
  logger.error('WhatsApp message handler error:', err);
213
- await sock.sendMessage(sender, { text: 'Something went wrong processing your message. Check daemon logs for details.' }).catch(() => { });
249
+ await sock
250
+ .sendMessage(sender, {
251
+ text: 'Something went wrong processing your message. Check daemon logs for details.',
252
+ })
253
+ .catch((sendErr) => logger.error('WhatsApp: failed to send fallback error message:', sendErr));
214
254
  }
215
255
  });
216
256
  }
@@ -231,10 +271,12 @@ export class WhatsAppChannel {
231
271
  const sock = this.sock;
232
272
  if (!sock)
233
273
  return;
234
- const chunks = chunkText(text, WHATSAPP_MAX_LENGTH);
235
- for (const chunk of chunks) {
236
- await retryWithBackoff(() => sock.sendMessage(peerId, { text: chunk }), [1000, 5000, 15000], 'whatsapp-send');
237
- }
274
+ await sendChunkedResponse({
275
+ text,
276
+ maxLength: WHATSAPP_MAX_LENGTH,
277
+ retryLabel: 'whatsapp-send',
278
+ sendChunk: (chunk) => sock.sendMessage(peerId, { text: chunk }),
279
+ });
238
280
  }
239
281
  async sendNotification(message, _urgent) {
240
282
  const sock = this.sock;
@@ -256,21 +298,20 @@ export class WhatsAppChannel {
256
298
  const status = active ? 'composing' : 'paused';
257
299
  await sock.sendPresenceUpdate(status, peerId).catch(() => { });
258
300
  }
259
- onMessage(_handler) {
260
- // Messages are handled directly in start()
261
- }
262
301
  // ─── Private ───
263
302
  async sendResponse(jid, text, tabName) {
264
- const prefix = tabName && tabName !== 'default' ? `[${tabName}] ` : '';
265
- const chunks = chunkText(prefix + text, WHATSAPP_MAX_LENGTH);
266
303
  const sock = this.sock;
267
- for (const chunk of chunks) {
268
- try {
269
- await retryWithBackoff(() => sock.sendMessage(jid, { text: chunk }), [1000, 5000, 15000], 'whatsapp-send');
270
- }
271
- catch (err) {
272
- logger.error(`WhatsApp delivery failed for ${jid}:`, err);
273
- }
304
+ try {
305
+ await sendChunkedResponse({
306
+ text,
307
+ tabName,
308
+ maxLength: WHATSAPP_MAX_LENGTH,
309
+ retryLabel: 'whatsapp-send',
310
+ sendChunk: (chunk) => sock.sendMessage(jid, { text: chunk }),
311
+ });
312
+ }
313
+ catch (err) {
314
+ logger.error(`WhatsApp delivery failed for ${jid}:`, err);
274
315
  }
275
316
  }
276
317
  isAllowed(jid) {
@@ -1,15 +1,15 @@
1
1
  import readline from 'node:readline';
2
2
  function ask(rl, question, defaultValue) {
3
3
  const prompt = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
4
- return new Promise(r => rl.question(prompt, a => r(a.trim() || defaultValue || '')));
4
+ return new Promise((r) => rl.question(prompt, (a) => r(a.trim() || defaultValue || '')));
5
5
  }
6
6
  export async function enableCapability(packId) {
7
7
  const { getAvailablePacks, isEnabled, enablePack } = await import('../capabilities/index.js');
8
8
  const packs = getAvailablePacks();
9
- const pack = packs.find(p => p.id === packId);
9
+ const pack = packs.find((p) => p.id === packId);
10
10
  if (!pack) {
11
11
  console.log(`Unknown capability: "${packId}"`);
12
- console.log('Available: ' + packs.map(p => p.id).join(', '));
12
+ console.log('Available: ' + packs.map((p) => p.id).join(', '));
13
13
  return;
14
14
  }
15
15
  if (isEnabled(packId)) {
@@ -46,7 +46,7 @@ export async function listCapabilities() {
46
46
  web: 'Web',
47
47
  };
48
48
  for (const category of categories) {
49
- const categoryPacks = packs.filter(p => p.category === category);
49
+ const categoryPacks = packs.filter((p) => p.category === category);
50
50
  if (categoryPacks.length === 0)
51
51
  continue;
52
52
  console.log(` ${categoryNames[category]}:`);