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
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Shared admin check for channel commands. Single source of truth so the
3
+ * "who can run admin commands?" rule doesn't drift between channels.
4
+ *
5
+ * Policy:
6
+ * - If an explicit admin peer ID is configured, only they are admin.
7
+ * - Else the first allowed peer in the allowlist is admin.
8
+ * - If the allowlist is empty, no one is admin (fail closed).
9
+ */
10
+ export declare function isChannelAdmin(allowList: Iterable<string | number>, peerId: string | number | undefined, explicitAdmin?: string | number): boolean;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared admin check for channel commands. Single source of truth so the
3
+ * "who can run admin commands?" rule doesn't drift between channels.
4
+ *
5
+ * Policy:
6
+ * - If an explicit admin peer ID is configured, only they are admin.
7
+ * - Else the first allowed peer in the allowlist is admin.
8
+ * - If the allowlist is empty, no one is admin (fail closed).
9
+ */
10
+ export function isChannelAdmin(allowList, peerId, explicitAdmin) {
11
+ if (peerId === undefined || peerId === null)
12
+ return false;
13
+ if (explicitAdmin !== undefined && explicitAdmin !== null) {
14
+ return String(peerId) === String(explicitAdmin);
15
+ }
16
+ const first = [...allowList][0];
17
+ if (first === undefined)
18
+ return false;
19
+ return String(peerId) === String(first);
20
+ }
@@ -9,19 +9,11 @@ export interface CommandResult {
9
9
  handled: boolean;
10
10
  response?: string;
11
11
  }
12
- export interface RouteResult {
13
- effectiveTabName: string;
14
- projectPath?: string;
15
- confirmationMessage?: string;
16
- }
12
+ export type { RouteResult } from '../projects/router.js';
17
13
  /**
18
14
  * Handle shared commands that work identically across all channels.
19
15
  * Returns { handled: true, response } if a command was matched.
20
16
  * The channel is responsible for sending the response via its own API.
21
17
  */
22
18
  export declare function handleSharedCommand(ctx: CommandContext, tabManager: TabManager): Promise<CommandResult>;
23
- /**
24
- * Shared project routing logic — resolves which tab/project to use for a message.
25
- * Extracted from the identical blocks in Telegram, WhatsApp, and Discord channels.
26
- */
27
- export declare function resolveProjectRoute(rawPrompt: string, tabName: string, text: string, userId: string): Promise<RouteResult>;
19
+ export { resolveProjectRoute } from '../projects/router.js';
@@ -32,12 +32,22 @@ export async function handleSharedCommand(ctx, tabManager) {
32
32
  const rest = text.slice(5);
33
33
  const setPromptMatch = rest.match(/^(\S+)\s+--set-prompt\s+"([^"]+)"/);
34
34
  if (setPromptMatch) {
35
+ if (!isAdmin)
36
+ return { handled: true, response: 'Only admin can change system prompts.' };
35
37
  const tabName = setPromptMatch[1];
38
+ if (tabName !== 'default') {
39
+ const nameErr = validateTabName(tabName);
40
+ if (nameErr)
41
+ return { handled: true, response: `Invalid tab name: ${nameErr}` };
42
+ }
36
43
  const systemPrompt = setPromptMatch[2];
37
- const { getDb } = await import('../db/index.js');
38
- const db = getDb();
39
- db.prepare('UPDATE tabs SET system_prompt = ? WHERE name = ?').run(systemPrompt, tabName);
40
- return { handled: true, response: `System prompt updated for tab "${tabName}"` };
44
+ const updated = tabManager.setSystemPrompt(tabName, systemPrompt);
45
+ return {
46
+ handled: true,
47
+ response: updated
48
+ ? `System prompt updated for tab "${tabName}"`
49
+ : `Tab "${tabName}" not found.`,
50
+ };
41
51
  }
42
52
  const spaceIdx = rest.indexOf(' ');
43
53
  if (spaceIdx === -1) {
@@ -51,42 +61,9 @@ export async function handleSharedCommand(ctx, tabManager) {
51
61
  // /tab with a valid name + message — not handled here, falls through to message handling
52
62
  return { handled: false };
53
63
  }
54
- // /register [name]
55
- if (text === '/register' || text.startsWith('/register ')) {
56
- const { resolveUser, registerUser, hasAdmin } = await import('../users/index.js');
57
- const existing = resolveUser(ctx.channelId, userId);
58
- if (existing) {
59
- return { handled: true, response: `You're already registered as "${existing.name}" (${existing.role}).` };
60
- }
61
- const name = text.slice(10).trim() || `user-${userId}`;
62
- const role = hasAdmin() ? 'user' : 'admin';
63
- const user = registerUser(name, ctx.channelId, userId, role);
64
- return { handled: true, response: `Registered as "${user.name}" (${user.role}).${role === 'admin' ? ' You are the admin.' : ''}` };
65
- }
66
- // /link channel:peerId
67
- if (text.startsWith('/link ')) {
68
- const { resolveUser, linkIdentity } = await import('../users/index.js');
69
- const user = resolveUser(ctx.channelId, userId);
70
- if (!user)
71
- return { handled: true, response: 'Register first: /register' };
72
- const parts = text.slice(6).trim().split(':');
73
- if (parts.length !== 2) {
74
- return { handled: true, response: 'Usage: /link channel:peerId (e.g., /link discord:123456789)' };
75
- }
76
- const success = linkIdentity(user.id, parts[0], parts[1]);
77
- return { handled: true, response: success ? `Linked ${parts[0]} identity.` : 'Failed to link — already linked or invalid.' };
78
- }
79
- // /users (admin only)
80
- if (text === '/users') {
81
- if (!isAdmin)
82
- return { handled: true, response: 'Admin only.' };
83
- const { listUsers } = await import('../users/index.js');
84
- const users = listUsers();
85
- if (users.length === 0)
86
- return { handled: true, response: 'No registered users.' };
87
- const list = users.map(u => `• ${u.name} [${u.role}] — ${u.id.slice(0, 8)}`).join('\n');
88
- return { handled: true, response: `${users.length} user(s):\n${list}` };
89
- }
64
+ // /register, /link, /users were part of unused multi-user scaffolding —
65
+ // removed in the audit fix pass. Beecork is single-user; admin is the first
66
+ // allowedUserId on Telegram (or config.telegram.adminUserId).
90
67
  // /watches
91
68
  if (text === '/watches' || text.startsWith('/watches@')) {
92
69
  const { getDb } = await import('../db/index.js');
@@ -104,7 +81,7 @@ export async function handleSharedCommand(ctx, tabManager) {
104
81
  if (text === '/tasks' || text.startsWith('/tasks@')) {
105
82
  const { getDb } = await import('../db/index.js');
106
83
  const db = getDb();
107
- const tasks = db.prepare('SELECT * FROM tasks WHERE user_id = ? ORDER BY created_at').all('local');
84
+ const tasks = db.prepare('SELECT * FROM tasks ORDER BY created_at').all();
108
85
  if (tasks.length === 0)
109
86
  return { handled: true, response: 'No tasks scheduled.' };
110
87
  const taskList = tasks.map((t) => {
@@ -179,10 +156,7 @@ export async function handleSharedCommand(ctx, tabManager) {
179
156
  const tabNameToClose = text.slice(7).trim();
180
157
  if (!tabNameToClose)
181
158
  return { handled: true, response: 'Usage: /close <tabname>' };
182
- // Stop any running subprocess before deleting records
183
- tabManager.stopTab(tabNameToClose);
184
- const { closeTab } = await import('../projects/index.js');
185
- const closed = closeTab(tabNameToClose);
159
+ const closed = tabManager.closeTab(tabNameToClose);
186
160
  return { handled: true, response: closed ? `Tab "${tabNameToClose}" permanently closed. History deleted.` : `Tab "${tabNameToClose}" not found.` };
187
161
  }
188
162
  // /fresh <folder>
@@ -196,34 +170,34 @@ export async function handleSharedCommand(ctx, tabManager) {
196
170
  setUserContext(userId, project.name, freshTabName);
197
171
  return { handled: true, response: `Fresh start in "${folderName}" (tab: ${freshTabName})\nSend your message now.` };
198
172
  }
199
- return { handled: false };
200
- }
201
- /**
202
- * Shared project routing logic resolves which tab/project to use for a message.
203
- * Extracted from the identical blocks in Telegram, WhatsApp, and Discord channels.
204
- */
205
- export async function resolveProjectRoute(rawPrompt, tabName, text, userId) {
206
- if (tabName !== 'default' || text.startsWith('/tab ')) {
207
- return { effectiveTabName: tabName };
208
- }
209
- try {
210
- const { routeMessage, setUserContext, listProjects } = await import('../projects/index.js');
211
- const decision = routeMessage(rawPrompt, { userId });
212
- if (decision.needsConfirmation) {
213
- const projects = listProjects().filter((p) => p.type === 'user-project');
214
- const options = projects.map((p, i) => `${i + 1}) ${p.name}`).join('\n');
215
- return {
216
- effectiveTabName: tabName,
217
- confirmationMessage: `Which project?\n${options}\n\nReply with the number, or just send your message with /project <name> first.`,
218
- };
173
+ // /history [date|yesterday]
174
+ if (text === '/history' || text.startsWith('/history ')) {
175
+ const dateArg = text.slice(9).trim();
176
+ const { getTimeline, formatTimeline } = await import('../timeline/index.js');
177
+ let date;
178
+ if (dateArg === 'yesterday') {
179
+ date = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
219
180
  }
220
- setUserContext(userId, decision.project.name, decision.tabName);
221
- return {
222
- effectiveTabName: decision.tabName,
223
- projectPath: decision.project.path,
224
- };
225
- }
226
- catch {
227
- return { effectiveTabName: tabName };
181
+ else if (dateArg) {
182
+ date = dateArg;
183
+ }
184
+ else {
185
+ date = new Date().toISOString().slice(0, 10);
186
+ }
187
+ const events = getTimeline({ date, limit: 30 });
188
+ return { handled: true, response: formatTimeline(events) };
189
+ }
190
+ // /knowledge
191
+ if (text === '/knowledge') {
192
+ const { getAllKnowledge, formatKnowledgeForContext } = await import('../knowledge/index.js');
193
+ const entries = getAllKnowledge();
194
+ if (entries.length === 0) {
195
+ return { handled: true, response: 'No knowledge stored yet. Beecork learns from your conversations.' };
196
+ }
197
+ return { handled: true, response: formatKnowledgeForContext(entries).slice(0, 4000) };
228
198
  }
199
+ return { handled: false };
229
200
  }
201
+ // resolveProjectRoute lives at src/projects/router.ts — re-exported here so
202
+ // existing callers (channels/pipeline.ts) don't need to update their import path.
203
+ export { resolveProjectRoute } from '../projects/router.js';
@@ -8,9 +8,7 @@ export declare class DiscordChannel implements Channel {
8
8
  private client;
9
9
  private ctx;
10
10
  private allowedUserIds;
11
- private sttProvider;
12
- private ttsProvider;
13
- private sttWarmedUp;
11
+ private voice;
14
12
  constructor(ctx: ChannelContext);
15
13
  start(): Promise<void>;
16
14
  stop(): void;
@@ -1,10 +1,11 @@
1
1
  import { logger } from '../util/logger.js';
2
- import { chunkText, parseTabMessage } from '../util/text.js';
2
+ import { chunkText, formatTabbedResponse, parseTabMessage } from '../util/text.js';
3
3
  import { retryWithBackoff } from '../util/retry.js';
4
4
  import { inboundLimiter } from '../util/rate-limiter.js';
5
5
  import { saveMedia, isOversized } from '../media/store.js';
6
- import { initVoiceProviders } from '../voice/index.js';
6
+ import { VoiceState } from './voice-state.js';
7
7
  import { processInboundMessage } from './pipeline.js';
8
+ import { isChannelAdmin } from './admin.js';
8
9
  export class DiscordChannel {
9
10
  id = 'discord';
10
11
  name = 'Discord';
@@ -14,9 +15,7 @@ export class DiscordChannel {
14
15
  client = null; // Discord.js Client
15
16
  ctx;
16
17
  allowedUserIds;
17
- sttProvider = null;
18
- ttsProvider = null;
19
- sttWarmedUp = false;
18
+ voice = new VoiceState('discord');
20
19
  constructor(ctx) {
21
20
  this.ctx = ctx;
22
21
  this.allowedUserIds = new Set((ctx.config.discord?.allowedUserIds ?? []).map(String));
@@ -37,10 +36,8 @@ export class DiscordChannel {
37
36
  GatewayIntentBits.MessageContent,
38
37
  ],
39
38
  });
40
- // Voice providers
41
- const { stt, tts } = initVoiceProviders(this.ctx.config.voice);
42
- this.sttProvider = stt;
43
- this.ttsProvider = tts;
39
+ // Voice providers (STT + TTS)
40
+ this.voice.init(this.ctx.config);
44
41
  this.client.on(Events.MessageCreate, async (message) => {
45
42
  // Ignore bot messages
46
43
  if (message.author.bot)
@@ -65,11 +62,10 @@ export class DiscordChannel {
65
62
  const text = message.content
66
63
  .replace(/<@!?\d+>/g, '') // Remove mentions
67
64
  .trim();
68
- // Warm up STT connection on first message with attachments
69
- if (this.sttProvider && !this.sttWarmedUp && message.attachments.size > 0) {
70
- this.sttProvider.warmup?.();
71
- this.sttWarmedUp = true;
72
- }
65
+ // Warm up STT connection on first message with attachments.
66
+ // (Discord intentionally only warms up; it doesn't transcribe like Telegram/WhatsApp.)
67
+ if (message.attachments.size > 0)
68
+ await this.voice.warmup();
73
69
  // Download attachments
74
70
  const media = [];
75
71
  for (const attachment of message.attachments.values()) {
@@ -115,7 +111,7 @@ export class DiscordChannel {
115
111
  const cmdResult = await handleSharedCommand({
116
112
  userId: message.author.id,
117
113
  text,
118
- isAdmin: this.allowedUserIds.size > 0 && message.author.id === [...this.allowedUserIds][0],
114
+ isAdmin: isChannelAdmin(this.allowedUserIds, message.author.id, this.ctx.config.discord?.adminUserId),
119
115
  channelId: 'discord',
120
116
  }, this.ctx.tabManager);
121
117
  if (cmdResult.handled) {
@@ -127,12 +123,16 @@ export class DiscordChannel {
127
123
  // Discord-specific: use thread name as tab if in a thread
128
124
  let overrideTabName;
129
125
  if (message.channel.isThread?.()) {
130
- const threadName = (message.channel.name || '')
126
+ const sanitized = (message.channel.name || '')
131
127
  .replace(/[^a-zA-Z0-9-]/g, '-')
132
128
  .replace(/^-+|-+$/g, '')
133
129
  .slice(0, 32);
134
- if (threadName && tabName === 'default')
135
- overrideTabName = threadName;
130
+ // Run the synthesized name through validateTabName so weird thread
131
+ // names (empty, starts with hyphen, "default") don't blow up downstream.
132
+ const { validateTabName } = await import('../config.js');
133
+ if (sanitized && tabName === 'default' && !validateTabName(sanitized)) {
134
+ overrideTabName = sanitized;
135
+ }
136
136
  }
137
137
  // Typing indicator refresh
138
138
  const typingInterval = setInterval(() => {
@@ -146,7 +146,7 @@ export class DiscordChannel {
146
146
  channelId: 'discord',
147
147
  tabManager: this.ctx.tabManager,
148
148
  voiceReplyMode: this.ctx.config.voice?.replyMode,
149
- ttsProvider: this.ttsProvider,
149
+ ttsProvider: this.voice.tts,
150
150
  userId: message.author.id,
151
151
  sendProgress: (msg) => {
152
152
  message.channel.send(msg).catch(() => { });
@@ -235,15 +235,15 @@ export class DiscordChannel {
235
235
  catch { }
236
236
  }
237
237
  async sendResponse(message, text, tabName) {
238
- const prefix = tabName && tabName !== 'default' ? `[${tabName}] ` : '';
239
- const fullText = prefix + text;
240
- const chunks = chunkText(fullText, this.maxMessageLength);
241
- // First chunk as reply, rest as follow-ups
242
- if (chunks.length > 0) {
243
- await retryWithBackoff(() => message.reply(chunks[0]), [1000, 5000], 'discord-reply');
244
- }
245
- for (let i = 1; i < chunks.length; i++) {
246
- await retryWithBackoff(() => message.channel.send(chunks[i]), [1000, 5000], 'discord-send');
238
+ // Discord quirk: first chunk uses message.reply so it threads under the
239
+ // original user message; follow-ups use channel.send. The shared helper
240
+ // takes a sendChunk callback so each channel keeps its platform-specific
241
+ // dispatch while sharing chunk + prefix + retry logic.
242
+ const full = formatTabbedResponse(text, tabName);
243
+ const chunks = chunkText(full, this.maxMessageLength);
244
+ for (let i = 0; i < chunks.length; i++) {
245
+ const isFirst = i === 0;
246
+ await retryWithBackoff(() => isFirst ? message.reply(chunks[i]) : message.channel.send(chunks[i]), [1000, 5000], isFirst ? 'discord-reply' : 'discord-send');
247
247
  }
248
248
  }
249
249
  }
@@ -37,7 +37,6 @@ export async function loadCommunityChannels(ctx) {
37
37
  continue;
38
38
  if (!allowlist.includes(dir))
39
39
  continue;
40
- const channelName = dir.slice(CHANNEL_PREFIX.length);
41
40
  const pkgPath = path.join(searchPath, dir);
42
41
  try {
43
42
  // Read package.json to find the main entry
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Send a tab-prefixed, chunked, retried response through any channel.
3
+ *
4
+ * Each channel just provides:
5
+ * - sendChunk(text): the platform-specific send call for one chunk
6
+ * - maxLength: the platform's per-message limit
7
+ * - retryLabel: label for logger (e.g. "telegram-send")
8
+ *
9
+ * Replaces near-identical per-channel implementations that had already
10
+ * drifted in subtle ways (different prefix gates, different retry envelopes).
11
+ */
12
+ export declare function sendChunkedResponse(opts: {
13
+ text: string;
14
+ tabName?: string;
15
+ maxLength: number;
16
+ retryLabel: string;
17
+ retryDelays?: number[];
18
+ sendChunk: (chunk: string) => Promise<unknown>;
19
+ }): Promise<void>;
@@ -0,0 +1,21 @@
1
+ import { chunkText, formatTabbedResponse } from '../util/text.js';
2
+ import { retryWithBackoff } from '../util/retry.js';
3
+ /**
4
+ * Send a tab-prefixed, chunked, retried response through any channel.
5
+ *
6
+ * Each channel just provides:
7
+ * - sendChunk(text): the platform-specific send call for one chunk
8
+ * - maxLength: the platform's per-message limit
9
+ * - retryLabel: label for logger (e.g. "telegram-send")
10
+ *
11
+ * Replaces near-identical per-channel implementations that had already
12
+ * drifted in subtle ways (different prefix gates, different retry envelopes).
13
+ */
14
+ export async function sendChunkedResponse(opts) {
15
+ const full = formatTabbedResponse(opts.text, opts.tabName);
16
+ const chunks = chunkText(full, opts.maxLength);
17
+ const delays = opts.retryDelays ?? [1000, 5000, 15000];
18
+ for (const chunk of chunks) {
19
+ await retryWithBackoff(() => opts.sendChunk(chunk), delays, opts.retryLabel);
20
+ }
21
+ }
@@ -1,10 +1,4 @@
1
1
  import type { Channel, ChannelContext, InboundMessageHandler, SendOptions } from './types.js';
2
- /** Format tab status for Telegram display */
3
- export declare function formatTabStatus(tabs: Array<{
4
- name: string;
5
- status: string;
6
- lastActivityAt: string;
7
- }>): string;
8
2
  export declare class TelegramChannel implements Channel {
9
3
  readonly id = "telegram";
10
4
  readonly name = "Telegram";
@@ -14,13 +8,11 @@ export declare class TelegramChannel implements Channel {
14
8
  private bot;
15
9
  private ctx;
16
10
  private activeChatIds;
17
- private sttProvider;
18
- private ttsProvider;
11
+ private voice;
19
12
  private botUserId;
20
13
  private botUsername;
21
14
  private mutedGroups;
22
15
  private welcomeSent;
23
- private sttWarmedUp;
24
16
  constructor(ctx: ChannelContext);
25
17
  start(): Promise<void>;
26
18
  stop(): void;
@@ -1,24 +1,23 @@
1
1
  import TelegramBot from 'node-telegram-bot-api';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { chunkText, timeAgo, parseTabMessage } from '../util/text.js';
4
+ import { chunkText, parseTabMessage, formatTabbedResponse } from '../util/text.js';
5
5
  import { logger } from '../util/logger.js';
6
6
  import { retryWithBackoff } from '../util/retry.js';
7
- import { getAdminUserId } from '../config.js';
8
7
  import { getLogsDir } from '../util/paths.js';
9
8
  import { saveMedia, isOversized } from '../media/store.js';
10
9
  import { inboundLimiter, groupLimiter } from '../util/rate-limiter.js';
11
10
  import { processInboundMessage } from './pipeline.js';
12
- import { initVoiceProviders } from '../voice/index.js';
11
+ import { isChannelAdmin } from './admin.js';
12
+ import { VoiceState } from './voice-state.js';
13
13
  const DEFAULT_GROUP_CONFIG = { activationMode: 'mention', maxResponsesPerMinute: 3, tabPerGroup: true };
14
- /** Format tab status for Telegram display */
15
- export function formatTabStatus(tabs) {
16
- if (tabs.length === 0)
17
- return 'No tabs.';
18
- return tabs.map(t => {
19
- const ago = timeAgo(t.lastActivityAt);
20
- return `• ${t.name} [${t.status}] — ${ago}`;
21
- }).join('\n');
14
+ /**
15
+ * Strip Telegram bot tokens out of strings before logging. Telegram embeds
16
+ * the token in the URL path (e.g. https://api.telegram.org/bot1234:abc.../method),
17
+ * so on fetch errors the message/cause can leak the token to disk.
18
+ */
19
+ function sanitizeBotToken(text) {
20
+ return text.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot<REDACTED>');
22
21
  }
23
22
  export class TelegramChannel {
24
23
  id = 'telegram';
@@ -29,13 +28,11 @@ export class TelegramChannel {
29
28
  bot;
30
29
  ctx;
31
30
  activeChatIds = new Set();
32
- sttProvider = null;
33
- ttsProvider = null;
31
+ voice = new VoiceState('telegram');
34
32
  botUserId = null;
35
33
  botUsername = null;
36
34
  mutedGroups = new Set();
37
35
  welcomeSent = new Set();
38
- sttWarmedUp = false;
39
36
  constructor(ctx) {
40
37
  this.ctx = ctx;
41
38
  this.bot = new TelegramBot(ctx.config.telegram.token, {
@@ -57,10 +54,8 @@ export class TelegramChannel {
57
54
  catch (err) {
58
55
  logger.error('Failed to clear pending updates, starting anyway:', err);
59
56
  }
60
- // Initialize voice providers
61
- const { stt, tts } = initVoiceProviders(this.ctx.config.voice);
62
- this.sttProvider = stt;
63
- this.ttsProvider = tts;
57
+ // Initialize voice providers (STT + TTS)
58
+ this.voice.init(this.ctx.config);
64
59
  this.bot.startPolling();
65
60
  // Cache bot identity for group mention detection
66
61
  try {
@@ -101,7 +96,17 @@ export class TelegramChannel {
101
96
  await this.bot.sendMessage(userId, message);
102
97
  this.activeChatIds.add(userId);
103
98
  }
104
- catch { /* User hasn't started conversation yet */ }
99
+ catch (err) {
100
+ // Differentiate: 400 "chat not found" means the user has not started a
101
+ // conversation with the bot yet — silently skip. Anything else (rate
102
+ // limit, bot blocked, network) is a real delivery failure worth logging.
103
+ const errAny = err;
104
+ const status = errAny?.response?.statusCode;
105
+ const isChatNotFound = status === 400 || /chat not found/i.test(errAny?.message || '');
106
+ if (!isChatNotFound) {
107
+ logger.warn(`Telegram notify to ${userId} failed (status=${status ?? '?'}):`, sanitizeBotToken(errAny?.message || String(err)));
108
+ }
109
+ }
105
110
  }
106
111
  }
107
112
  async setTyping(peerId, active) {
@@ -138,9 +143,9 @@ export class TelegramChannel {
138
143
  'Send any message and I\'ll pass it to Claude Code.',
139
144
  '',
140
145
  'Quick tips:',
141
- '\u2022 `/tab name message` \u2014 organize work into tabs',
142
- '\u2022 `/tabs` \u2014 see what\'s running',
143
- '\u2022 `/stop name` \u2014 stop a tab',
146
+ '\u2022 /tab name message \u2014 organize work into tabs',
147
+ '\u2022 /tabs \u2014 see what\'s running',
148
+ '\u2022 /stop name \u2014 stop a tab',
144
149
  '',
145
150
  'Let\'s get started! Send me something.',
146
151
  ].join('\n'));
@@ -230,10 +235,7 @@ export class TelegramChannel {
230
235
  .filter((r) => r.status === 'fulfilled' && r.value !== null)
231
236
  .map(r => r.value);
232
237
  // Transcribe voice messages if STT is configured
233
- if (this.sttProvider) {
234
- const { transcribeVoiceMessages } = await import('../voice/index.js');
235
- this.sttWarmedUp = await transcribeVoiceMessages(media, this.sttProvider, 'telegram', this.sttWarmedUp);
236
- }
238
+ await this.voice.transcribe(media);
237
239
  // Skip if no text AND no media
238
240
  if (!text && media.length === 0)
239
241
  return;
@@ -255,7 +257,10 @@ export class TelegramChannel {
255
257
  }
256
258
  catch (err) {
257
259
  logger.error('Telegram: error handling message:', err);
258
- await this.bot.sendMessage(chatId, 'Something went wrong processing your message. Check daemon logs for details.');
260
+ // Wrap the fallback send so a Telegram outage doesn't escalate to an
261
+ // unhandledRejection on the message-event handler.
262
+ this.bot.sendMessage(chatId, 'Something went wrong processing your message. Check daemon logs for details.')
263
+ .catch(sendErr => logger.error('Telegram: failed to send fallback error message:', sendErr));
259
264
  }
260
265
  });
261
266
  }
@@ -271,35 +276,8 @@ export class TelegramChannel {
271
276
  await this.bot.sendMessage(chatId, 'Beecork unmuted in this group.');
272
277
  return;
273
278
  }
274
- if (text === '/history' || text.startsWith('/history ')) {
275
- const dateArg = text.slice(9).trim();
276
- const { getTimeline, formatTimeline } = await import('../timeline/index.js');
277
- let date;
278
- if (dateArg === 'yesterday') {
279
- date = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
280
- }
281
- else if (dateArg) {
282
- date = dateArg;
283
- }
284
- else {
285
- date = new Date().toISOString().slice(0, 10);
286
- }
287
- const events = getTimeline({ date, limit: 30 });
288
- await this.sendResponse(chatId, formatTimeline(events));
289
- return;
290
- }
291
- if (text === '/knowledge') {
292
- const { getAllKnowledge, formatKnowledgeForContext } = await import('../knowledge/index.js');
293
- const entries = getAllKnowledge();
294
- if (entries.length === 0) {
295
- await this.bot.sendMessage(chatId, 'No knowledge stored yet. Beecork learns from your conversations.');
296
- return;
297
- }
298
- const formatted = formatKnowledgeForContext(entries);
299
- await this.sendResponse(chatId, formatted.slice(0, 4000));
300
- return;
301
- }
302
- // Shared command handler (covers /tabs, /stop, /tab, /projects, /project, /newproject, /close, /fresh, /register, /link, /users, /cost, /activity, /handoff)
279
+ // /history and /knowledge now handled by the shared command handler.
280
+ // Shared command handler (covers /tabs, /stop, /tab, /projects, /project, /newproject, /close, /fresh, /cost, /activity, /handoff, /history, /knowledge)
303
281
  const { handleSharedCommand } = await import('./command-handler.js');
304
282
  const result = await handleSharedCommand({
305
283
  userId: String(userId || 'default'),
@@ -381,7 +359,7 @@ export class TelegramChannel {
381
359
  channelId: 'telegram',
382
360
  tabManager: this.ctx.tabManager,
383
361
  voiceReplyMode: this.ctx.config.voice?.replyMode,
384
- ttsProvider: this.ttsProvider,
362
+ ttsProvider: this.voice.tts,
385
363
  userId: String(chatId),
386
364
  sendProgress: (msg) => {
387
365
  this.bot.sendMessage(chatId, msg).catch(() => { });
@@ -457,9 +435,9 @@ export class TelegramChannel {
457
435
  }
458
436
  }
459
437
  async sendResponse(chatId, text, tabName) {
460
- const prefix = tabName && tabName !== 'default' ? `[${tabName}] ` : '';
461
- const fullText = prefix + text;
438
+ const fullText = formatTabbedResponse(text, tabName);
462
439
  const chunks = chunkText(fullText);
440
+ // Telegram-specific: if the response would be >10 chunks, send a preview + the rest as a file.
463
441
  if (chunks.length > 10) {
464
442
  for (let i = 0; i < 3; i++) {
465
443
  await this.sendWithRetry(chatId, chunks[i]);
@@ -476,18 +454,16 @@ export class TelegramChannel {
476
454
  }
477
455
  async sendWithRetry(chatId, text) {
478
456
  try {
479
- await retryWithBackoff(async () => {
480
- try {
481
- await this.bot.sendMessage(chatId, text, { parse_mode: 'Markdown' });
482
- }
483
- catch {
484
- await this.bot.sendMessage(chatId, text);
485
- }
486
- }, [1000, 5000, 15000], 'telegram-send');
457
+ await retryWithBackoff(
458
+ // Send as plain text — Telegram's legacy "Markdown" parser silently mangles
459
+ // underscores/asterisks in Claude's responses (code identifiers, names),
460
+ // and Beecork has no escaping pass for it.
461
+ () => this.bot.sendMessage(chatId, text), [1000, 5000, 15000], 'telegram-send');
487
462
  }
488
463
  catch (err) {
489
464
  const failLog = path.join(getLogsDir(), 'delivery-failures.log');
490
- const entry = `[${new Date().toISOString()}] chatId=${chatId} error=${err instanceof Error ? err.message : err} text=${text.slice(0, 200)}\n`;
465
+ const sanitizedErr = sanitizeBotToken(err instanceof Error ? err.message : String(err));
466
+ const entry = `[${new Date().toISOString()}] chatId=${chatId} error=${sanitizedErr} text=${text.slice(0, 200)}\n`;
491
467
  fs.appendFileSync(failLog, entry);
492
468
  logger.error(`Delivery failed after retries for chat ${chatId}`);
493
469
  }
@@ -514,9 +490,8 @@ export class TelegramChannel {
514
490
  return this.ctx.config.telegram.allowedUserIds.includes(userId);
515
491
  }
516
492
  isAdmin(userId) {
517
- if (!userId)
518
- return false;
519
- return userId === getAdminUserId();
493
+ const cfg = this.ctx.config.telegram;
494
+ return isChannelAdmin(cfg.allowedUserIds, userId, cfg.adminUserId);
520
495
  }
521
496
  async setReaction(chatId, messageId, emoji) {
522
497
  try {