beecork 1.4.11 → 1.5.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 (66) hide show
  1. package/dist/channels/admin.d.ts +10 -0
  2. package/dist/channels/admin.js +20 -0
  3. package/dist/channels/command-handler.d.ts +2 -10
  4. package/dist/channels/command-handler.js +47 -73
  5. package/dist/channels/discord.d.ts +1 -3
  6. package/dist/channels/discord.js +28 -28
  7. package/dist/channels/loader.js +0 -1
  8. package/dist/channels/send-helpers.d.ts +19 -0
  9. package/dist/channels/send-helpers.js +21 -0
  10. package/dist/channels/telegram.d.ts +1 -9
  11. package/dist/channels/telegram.js +46 -71
  12. package/dist/channels/types.d.ts +2 -10
  13. package/dist/channels/voice-state.d.ts +29 -0
  14. package/dist/channels/voice-state.js +43 -0
  15. package/dist/channels/webhook.d.ts +1 -1
  16. package/dist/channels/webhook.js +68 -24
  17. package/dist/channels/whatsapp.d.ts +1 -3
  18. package/dist/channels/whatsapp.js +79 -74
  19. package/dist/cli/doctor.js +5 -2
  20. package/dist/cli/handoff.js +6 -6
  21. package/dist/config.d.ts +5 -1
  22. package/dist/config.js +17 -14
  23. package/dist/daemon.js +29 -17
  24. package/dist/dashboard/html.js +20 -8
  25. package/dist/dashboard/routes.d.ts +17 -0
  26. package/dist/dashboard/routes.js +559 -0
  27. package/dist/dashboard/server.js +33 -488
  28. package/dist/db/index.js +16 -2
  29. package/dist/db/migrations.js +44 -8
  30. package/dist/mcp/handlers.d.ts +37 -0
  31. package/dist/mcp/handlers.js +451 -0
  32. package/dist/mcp/server.js +25 -849
  33. package/dist/mcp/tool-definitions.d.ts +1225 -0
  34. package/dist/mcp/tool-definitions.js +364 -0
  35. package/dist/media/index.d.ts +2 -7
  36. package/dist/media/index.js +1 -1
  37. package/dist/observability/analytics.d.ts +1 -1
  38. package/dist/observability/analytics.js +6 -3
  39. package/dist/projects/index.d.ts +3 -2
  40. package/dist/projects/index.js +2 -2
  41. package/dist/projects/manager.d.ts +1 -3
  42. package/dist/projects/manager.js +26 -25
  43. package/dist/projects/router.d.ts +10 -0
  44. package/dist/projects/router.js +28 -0
  45. package/dist/session/manager.d.ts +4 -0
  46. package/dist/session/manager.js +48 -42
  47. package/dist/session/subprocess.d.ts +1 -0
  48. package/dist/session/subprocess.js +21 -0
  49. package/dist/session/tab-store.d.ts +28 -0
  50. package/dist/session/tab-store.js +77 -0
  51. package/dist/tasks/scheduler.d.ts +6 -0
  52. package/dist/tasks/scheduler.js +52 -13
  53. package/dist/tasks/store.js +6 -6
  54. package/dist/timeline/query.js +6 -2
  55. package/dist/types.d.ts +15 -0
  56. package/dist/util/paths.d.ts +1 -0
  57. package/dist/util/paths.js +4 -1
  58. package/dist/util/rate-limiter.js +8 -0
  59. package/dist/util/text.d.ts +21 -1
  60. package/dist/util/text.js +25 -1
  61. package/dist/watchers/scheduler.js +2 -3
  62. package/package.json +1 -1
  63. package/dist/users/index.d.ts +0 -2
  64. package/dist/users/index.js +0 -1
  65. package/dist/users/service.d.ts +0 -17
  66. package/dist/users/service.js +0 -46
@@ -1,14 +1,6 @@
1
1
  import type { TabManager } from '../session/manager.js';
2
- import type { BeecorkConfig } from '../types.js';
3
- /** A media file attached to a message */
4
- export interface MediaAttachment {
5
- type: 'image' | 'audio' | 'video' | 'document' | 'voice';
6
- mimeType: string;
7
- filePath: string;
8
- fileName?: string;
9
- duration?: number;
10
- caption?: string;
11
- }
2
+ import type { BeecorkConfig, MediaAttachment } from '../types.js';
3
+ export type { MediaAttachment };
12
4
  /** An inbound message from any channel */
13
5
  export interface InboundMessage {
14
6
  channelId: string;
@@ -0,0 +1,29 @@
1
+ import type { STTProvider } from '../voice/stt.js';
2
+ import type { TTSProvider } from '../voice/tts.js';
3
+ import type { VoiceConfig, BeecorkConfig } from '../types.js';
4
+ import type { MediaAttachment } from '../types.js';
5
+ /**
6
+ * Per-channel STT/TTS state. Used to be duplicated across Telegram, WhatsApp,
7
+ * and Discord; consolidated here so init + warmup + transcription happen the
8
+ * same way across all 3.
9
+ *
10
+ * Discord historically only called warmup() and did NOT transcribe — preserve
11
+ * that behavior unless the caller explicitly asks for transcription.
12
+ */
13
+ export declare class VoiceState {
14
+ private readonly channelId;
15
+ stt: STTProvider | null;
16
+ tts: TTSProvider | null;
17
+ private warmedUp;
18
+ constructor(channelId: string);
19
+ init(config: BeecorkConfig | {
20
+ voice?: VoiceConfig;
21
+ }): void;
22
+ /** One-shot warmup (no media). Used by Discord today. */
23
+ warmup(): Promise<void>;
24
+ /**
25
+ * Transcribe voice attachments in-place (mutates the caption fields).
26
+ * Returns the updated warmed-up flag. No-op if STT isn't configured.
27
+ */
28
+ transcribe(media: MediaAttachment[]): Promise<void>;
29
+ }
@@ -0,0 +1,43 @@
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 { /* warmup is best-effort */ }
31
+ this.warmedUp = true;
32
+ }
33
+ /**
34
+ * Transcribe voice attachments in-place (mutates the caption fields).
35
+ * Returns the updated warmed-up flag. No-op if STT isn't configured.
36
+ */
37
+ async transcribe(media) {
38
+ if (!this.stt)
39
+ return;
40
+ const { transcribeVoiceMessages } = await import('../voice/index.js');
41
+ this.warmedUp = await transcribeVoiceMessages(media, this.stt, this.channelId, this.warmedUp);
42
+ }
43
+ }
@@ -2,7 +2,7 @@ import type { Channel, ChannelContext, InboundMessageHandler, SendOptions } from
2
2
  export declare class WebhookChannel implements Channel {
3
3
  readonly id = "webhook";
4
4
  readonly name = "Webhook";
5
- readonly maxMessageLength = 100000;
5
+ readonly maxMessageLength: 100000;
6
6
  readonly supportsStreaming = false;
7
7
  readonly supportsMedia = false;
8
8
  private server;
@@ -1,11 +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
23
+ maxMessageLength = MESSAGE_LIMITS.WEBHOOK_PROMPT;
9
24
  supportsStreaming = false;
10
25
  supportsMedia = false;
11
26
  server = null;
@@ -40,22 +55,21 @@ export class WebhookChannel {
40
55
  return;
41
56
  }
42
57
  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
- }
58
+ // Validate tab name (allow "default" — it's a reference, not a creation)
59
+ const tabError = validateTabNameOrDefault(tabName);
60
+ if (tabError) {
61
+ res.writeHead(400);
62
+ res.end(JSON.stringify({ error: tabError }));
63
+ return;
51
64
  }
52
65
  // Read body first (needed for both JSON parsing and HMAC verification)
53
66
  let body = '';
54
67
  for await (const chunk of req) {
55
68
  body += chunk;
56
- if (body.length > 1024 * 1024) { // 1MB limit
69
+ if (body.length > MESSAGE_LIMITS.HTTP_BODY) {
57
70
  res.writeHead(413);
58
71
  res.end(JSON.stringify({ error: 'Payload too large' }));
72
+ req.destroy();
59
73
  return;
60
74
  }
61
75
  }
@@ -65,6 +79,12 @@ export class WebhookChannel {
65
79
  res.end(JSON.stringify({ error: 'Unauthorized' }));
66
80
  return;
67
81
  }
82
+ // Rate-limit AFTER auth so unauthenticated callers don't burn the budget
83
+ if (!inboundLimiter.check(this.id)) {
84
+ res.writeHead(429);
85
+ res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
86
+ return;
87
+ }
68
88
  let payload;
69
89
  try {
70
90
  payload = JSON.parse(body);
@@ -81,23 +101,47 @@ export class WebhookChannel {
81
101
  return;
82
102
  }
83
103
  const isSync = payload.sync ?? false;
104
+ const remote = req.socket.remoteAddress ?? 'webhook';
84
105
  try {
85
106
  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);
107
+ // Sync mode: route through the shared pipeline so routing/enrichment apply.
108
+ const result = await processInboundMessage({
109
+ text: prompt,
110
+ media: [],
111
+ channelId: this.id,
112
+ tabManager: this.ctx.tabManager,
113
+ userId: remote,
114
+ sendProgress: () => { },
115
+ overrideTabName: tabName,
116
+ });
117
+ res.writeHead(result.isError ? 500 : 200);
89
118
  res.end(JSON.stringify({
90
- text: result.text,
91
- tab: tabName,
92
- costUsd: result.costUsd,
93
- durationMs: result.durationMs,
94
- error: result.error,
119
+ text: result.responseText,
120
+ tab: result.tabName,
121
+ error: result.isError ? result.responseText : undefined,
95
122
  }));
96
123
  }
97
124
  else {
98
- // Async mode: accept and process in background
99
- this.ctx.tabManager.sendMessage(tabName, prompt).catch(err => {
125
+ // Async mode: fire-and-forget through the pipeline.
126
+ // Surface failures to the user via broadcastNotify since the HTTP response
127
+ // is already 202 and the caller has no other way to learn.
128
+ processInboundMessage({
129
+ text: prompt,
130
+ media: [],
131
+ channelId: this.id,
132
+ tabManager: this.ctx.tabManager,
133
+ userId: remote,
134
+ sendProgress: () => { },
135
+ overrideTabName: tabName,
136
+ }).then(result => {
137
+ if (result.isError && this.ctx.notifyCallback) {
138
+ this.ctx.notifyCallback(`Webhook async failed for "${tabName}": ${result.responseText}`).catch(() => { });
139
+ }
140
+ }).catch(err => {
100
141
  logger.error(`Webhook async processing failed for tab ${tabName}:`, err);
142
+ if (this.ctx.notifyCallback) {
143
+ this.ctx.notifyCallback(`Webhook async failed for "${tabName}": ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
144
+ }
101
145
  });
102
146
  res.writeHead(202);
103
147
  res.end(JSON.stringify({ accepted: true, tab: tabName }));
@@ -136,10 +180,10 @@ export class WebhookChannel {
136
180
  // No auth configured = allow all (localhost only)
137
181
  if (!config.authToken && !config.hmacSecret)
138
182
  return true;
139
- // Bearer token auth
183
+ // Bearer token auth (constant-time compare)
140
184
  if (config.authToken) {
141
- const authHeader = req.headers.authorization;
142
- if (authHeader === `Bearer ${config.authToken}`)
185
+ const authHeader = req.headers.authorization || '';
186
+ if (safeEqualString(authHeader, `Bearer ${config.authToken}`))
143
187
  return true;
144
188
  }
145
189
  // HMAC signature auth (for GitHub-style webhooks)
@@ -11,9 +11,7 @@ export declare class WhatsAppChannel implements Channel {
11
11
  private reconnectAttempts;
12
12
  private readonly maxReconnectAttempts;
13
13
  private readonly backoffDelays;
14
- private sttProvider;
15
- private ttsProvider;
16
- private sttWarmedUp;
14
+ private voice;
17
15
  constructor(ctx: ChannelContext);
18
16
  start(): Promise<void>;
19
17
  stop(): void;
@@ -1,11 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import { logger } from '../util/logger.js';
3
3
  import { saveMedia, isOversized } from '../media/store.js';
4
- import { retryWithBackoff } from '../util/retry.js';
5
- import { chunkText } from '../util/text.js';
4
+ import { sendChunkedResponse } from './send-helpers.js';
6
5
  import { inboundLimiter } from '../util/rate-limiter.js';
7
6
  import { processInboundMessage } from './pipeline.js';
8
- import { initVoiceProviders } from '../voice/index.js';
7
+ import { isChannelAdmin } from './admin.js';
8
+ import { VoiceState } from './voice-state.js';
9
9
  const WHATSAPP_MAX_LENGTH = 8192;
10
10
  export class WhatsAppChannel {
11
11
  id = 'whatsapp';
@@ -19,18 +19,14 @@ export class WhatsAppChannel {
19
19
  reconnectAttempts = 0;
20
20
  maxReconnectAttempts = 10;
21
21
  backoffDelays = [1000, 5000, 15000, 30000, 60000];
22
- sttProvider = null;
23
- ttsProvider = null;
24
- sttWarmedUp = false;
22
+ voice = new VoiceState('whatsapp');
25
23
  constructor(ctx) {
26
24
  this.ctx = ctx;
27
25
  this.allowedNumbers = new Set(ctx.config.whatsapp?.allowedNumbers ?? []);
28
26
  }
29
27
  async start() {
30
- // Initialize voice providers
31
- const { stt, tts } = initVoiceProviders(this.ctx.config.voice);
32
- this.sttProvider = stt;
33
- this.ttsProvider = tts;
28
+ // Initialize voice providers (STT + TTS)
29
+ this.voice.init(this.ctx.config);
34
30
  try {
35
31
  const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, downloadMediaMessage, fetchLatestBaileysVersion } = await import('@whiskeysockets/baileys');
36
32
  const sessionPath = this.ctx.config.whatsapp?.sessionPath ?? `${process.env.HOME}/.beecork/whatsapp-session`;
@@ -101,56 +97,63 @@ export class WhatsAppChannel {
101
97
  msg.message.extendedTextMessage?.text ||
102
98
  msg.message.imageMessage?.caption ||
103
99
  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);
100
+ const descriptors = [
101
+ {
102
+ key: 'imageMessage',
103
+ build: (m, buf) => ({
104
+ type: 'image',
105
+ mimeType: m.imageMessage.mimetype || 'image/jpeg',
106
+ filePath: saveMedia(buf, 'jpg'),
107
+ }),
108
+ },
109
+ {
110
+ key: 'audioMessage',
111
+ build: (m, buf) => {
112
+ const ext = m.audioMessage.ptt ? 'ogg' : 'mp3';
123
113
  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,
114
+ type: m.audioMessage.ptt ? 'voice' : 'audio',
115
+ mimeType: m.audioMessage.mimetype || 'audio/ogg',
116
+ filePath: saveMedia(buf, ext),
117
+ duration: m.audioMessage.seconds ?? undefined,
128
118
  };
129
- }
130
- return null;
131
- })
132
- .catch(() => null));
133
- }
134
- if (msg.message.documentMessage) {
119
+ },
120
+ },
121
+ {
122
+ key: 'documentMessage',
123
+ build: (m, buf) => {
124
+ const ext = m.documentMessage.fileName?.split('.').pop() || 'bin';
125
+ return {
126
+ type: 'document',
127
+ mimeType: m.documentMessage.mimetype || 'application/octet-stream',
128
+ filePath: saveMedia(buf, ext, m.documentMessage.fileName ?? undefined),
129
+ fileName: m.documentMessage.fileName ?? undefined,
130
+ };
131
+ },
132
+ },
133
+ {
134
+ key: 'videoMessage',
135
+ build: (m, buf) => ({
136
+ type: 'video',
137
+ mimeType: m.videoMessage.mimetype || 'video/mp4',
138
+ filePath: saveMedia(buf, 'mp4'),
139
+ duration: m.videoMessage.seconds ?? undefined,
140
+ }),
141
+ },
142
+ ];
143
+ const waDownloadTasks = [];
144
+ for (const d of descriptors) {
145
+ if (!msg.message[d.key])
146
+ continue;
135
147
  waDownloadTasks.push(downloadMediaMessage(msg, 'buffer', {})
136
148
  .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 };
149
+ if (!buffer || isOversized(buffer.length))
150
+ return null;
151
+ try {
152
+ return d.build(msg.message, buffer);
141
153
  }
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 };
154
+ catch {
155
+ return null;
152
156
  }
153
- return null;
154
157
  })
155
158
  .catch(() => null));
156
159
  }
@@ -159,10 +162,7 @@ export class WhatsAppChannel {
159
162
  .filter((r) => r.status === 'fulfilled' && r.value !== null)
160
163
  .map(r => r.value);
161
164
  // 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
- }
165
+ await this.voice.transcribe(media);
166
166
  if (!text && media.length === 0)
167
167
  return;
168
168
  try {
@@ -173,7 +173,7 @@ export class WhatsAppChannel {
173
173
  const cmdResult = await handleSharedCommand({
174
174
  userId: waUserId,
175
175
  text,
176
- isAdmin: this.allowedNumbers.size > 0 && waUserId === [...this.allowedNumbers][0],
176
+ isAdmin: isChannelAdmin(this.allowedNumbers, waUserId, this.ctx.config.whatsapp?.adminNumber),
177
177
  channelId: 'whatsapp',
178
178
  }, this.ctx.tabManager);
179
179
  if (cmdResult.handled) {
@@ -190,7 +190,7 @@ export class WhatsAppChannel {
190
190
  channelId: 'whatsapp',
191
191
  tabManager: this.ctx.tabManager,
192
192
  voiceReplyMode: this.ctx.config.voice?.replyMode,
193
- ttsProvider: this.ttsProvider,
193
+ ttsProvider: this.voice.tts,
194
194
  userId: waUserId,
195
195
  sendProgress: (msg) => {
196
196
  sock.sendMessage(sender, { text: msg }).catch(() => { });
@@ -210,7 +210,8 @@ export class WhatsAppChannel {
210
210
  }
211
211
  catch (err) {
212
212
  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(() => { });
213
+ await sock.sendMessage(sender, { text: 'Something went wrong processing your message. Check daemon logs for details.' })
214
+ .catch((sendErr) => logger.error('WhatsApp: failed to send fallback error message:', sendErr));
214
215
  }
215
216
  });
216
217
  }
@@ -231,10 +232,12 @@ export class WhatsAppChannel {
231
232
  const sock = this.sock;
232
233
  if (!sock)
233
234
  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
- }
235
+ await sendChunkedResponse({
236
+ text,
237
+ maxLength: WHATSAPP_MAX_LENGTH,
238
+ retryLabel: 'whatsapp-send',
239
+ sendChunk: chunk => sock.sendMessage(peerId, { text: chunk }),
240
+ });
238
241
  }
239
242
  async sendNotification(message, _urgent) {
240
243
  const sock = this.sock;
@@ -261,16 +264,18 @@ export class WhatsAppChannel {
261
264
  }
262
265
  // ─── Private ───
263
266
  async sendResponse(jid, text, tabName) {
264
- const prefix = tabName && tabName !== 'default' ? `[${tabName}] ` : '';
265
- const chunks = chunkText(prefix + text, WHATSAPP_MAX_LENGTH);
266
267
  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
- }
268
+ try {
269
+ await sendChunkedResponse({
270
+ text,
271
+ tabName,
272
+ maxLength: WHATSAPP_MAX_LENGTH,
273
+ retryLabel: 'whatsapp-send',
274
+ sendChunk: chunk => sock.sendMessage(jid, { text: chunk }),
275
+ });
276
+ }
277
+ catch (err) {
278
+ logger.error(`WhatsApp delivery failed for ${jid}:`, err);
274
279
  }
275
280
  }
276
281
  isAllowed(jid) {
@@ -136,9 +136,12 @@ export async function runDoctor() {
136
136
  else {
137
137
  checks.push({ name: 'MCP config', status: 'pass', message: 'Default (beecork MCP only)' });
138
138
  }
139
- // Print results
139
+ // Print results — gate ANSI on a real TTY so piping to a file/grep yields plain text.
140
140
  console.log('\nBeecork Doctor\n');
141
- const icons = { pass: '\x1b[32m✓\x1b[0m', warn: '\x1b[33m!\x1b[0m', fail: '\x1b[31m✗\x1b[0m' };
141
+ const colored = process.stdout.isTTY && !process.env.NO_COLOR;
142
+ const icons = colored
143
+ ? { pass: '\x1b[32m✓\x1b[0m', warn: '\x1b[33m!\x1b[0m', fail: '\x1b[31m✗\x1b[0m' }
144
+ : { pass: '✓', warn: '!', fail: '✗' };
142
145
  for (const check of checks) {
143
146
  console.log(` ${icons[check.status]} ${check.name}: ${check.message}`);
144
147
  }
@@ -1,18 +1,18 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { getDb } from '../db/index.js';
3
3
  import { getConfig } from '../config.js';
4
+ import { TabStore } from '../session/tab-store.js';
4
5
  export function exportTab(tabName) {
5
- const db = getDb();
6
- const tab = db.prepare('SELECT * FROM tabs WHERE name = ?').get(tabName);
6
+ const tab = TabStore.findByName(tabName);
7
7
  if (!tab)
8
8
  return null;
9
- const messages = db.prepare('SELECT role, content FROM messages WHERE tab_id = ? ORDER BY created_at DESC LIMIT 5').all(tab.id);
9
+ const messages = getDb().prepare('SELECT role, content FROM messages WHERE tab_id = ? ORDER BY created_at DESC LIMIT 5').all(tab.id);
10
10
  return {
11
11
  name: tab.name,
12
- sessionId: tab.session_id,
13
- workingDir: tab.working_dir,
12
+ sessionId: tab.sessionId,
13
+ workingDir: tab.workingDir,
14
14
  status: tab.status,
15
- lastActivity: tab.last_activity_at,
15
+ lastActivity: tab.lastActivityAt,
16
16
  recentMessages: messages.reverse(),
17
17
  };
18
18
  }
package/dist/config.d.ts CHANGED
@@ -3,5 +3,9 @@ export declare function getConfig(): BeecorkConfig;
3
3
  export declare function saveConfig(config: BeecorkConfig): void;
4
4
  export declare function getTabConfig(tabName: string): TabConfig;
5
5
  export declare function resolveWorkingDir(tabName: string): string;
6
- export declare function getAdminUserId(): number;
7
6
  export declare function validateTabName(name: string): string | null;
7
+ /**
8
+ * Like validateTabName but allows the literal name "default" (used by send/update
9
+ * endpoints that reference an existing tab rather than creating one).
10
+ */
11
+ export declare function validateTabNameOrDefault(name: string): string | null;
package/dist/config.js CHANGED
@@ -53,8 +53,8 @@ export function getConfig() {
53
53
  export function saveConfig(config) {
54
54
  const configPath = getConfigPath();
55
55
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
56
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
57
- fs.chmodSync(configPath, 0o600); // Owner-only read/write contains API keys
56
+ // Owner-only mode set atomically with the write so there's no world-readable window.
57
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
58
58
  cachedConfig = config;
59
59
  }
60
60
  export function getTabConfig(tabName) {
@@ -65,10 +65,8 @@ export function resolveWorkingDir(tabName) {
65
65
  const tabConfig = getTabConfig(tabName);
66
66
  return expandHome(tabConfig.workingDir);
67
67
  }
68
- export function getAdminUserId() {
69
- const config = getConfig();
70
- return config.telegram.adminUserId ?? config.telegram.allowedUserIds[0];
71
- }
68
+ // getAdminUserId removed — admin check now lives in channels/admin.ts (isChannelAdmin)
69
+ // so all 3 channels share the same policy instead of each reimplementing.
72
70
  const TAB_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,31}$/;
73
71
  export function validateTabName(name) {
74
72
  if (name === 'default')
@@ -79,8 +77,21 @@ export function validateTabName(name) {
79
77
  return 'Tab name must be alphanumeric + hyphens, max 32 chars';
80
78
  return null; // valid
81
79
  }
80
+ /**
81
+ * Like validateTabName but allows the literal name "default" (used by send/update
82
+ * endpoints that reference an existing tab rather than creating one).
83
+ */
84
+ export function validateTabNameOrDefault(name) {
85
+ if (name === 'default')
86
+ return null;
87
+ return validateTabName(name);
88
+ }
82
89
  function mergeWithDefaults(raw) {
90
+ // Spread raw first so any future optional fields round-trip through saveConfig
91
+ // without needing to be enumerated here. Specific sections that need defaults
92
+ // get merged below.
83
93
  return {
94
+ ...raw,
84
95
  telegram: {
85
96
  ...DEFAULT_CONFIG.telegram,
86
97
  ...raw.telegram,
@@ -105,13 +116,5 @@ function mergeWithDefaults(raw) {
105
116
  ?? raw.pipe?.projectScanPaths
106
117
  ?? [...DEFAULT_PROJECT_SCAN_PATHS],
107
118
  deployment: raw.deployment ?? DEFAULT_CONFIG.deployment,
108
- // Preserve optional config sections (no defaults needed)
109
- whatsapp: raw.whatsapp,
110
- discord: raw.discord,
111
- webhook: raw.webhook,
112
- voice: raw.voice,
113
- groups: raw.groups,
114
- notifications: raw.notifications,
115
- mediaGenerators: raw.mediaGenerators,
116
119
  };
117
120
  }