beecork 1.5.0 → 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 (119) 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/command-handler.js +46 -14
  6. package/dist/channels/discord.d.ts +3 -6
  7. package/dist/channels/discord.js +40 -23
  8. package/dist/channels/index.d.ts +1 -1
  9. package/dist/channels/loader.js +13 -3
  10. package/dist/channels/pipeline.js +14 -5
  11. package/dist/channels/registry.d.ts +17 -1
  12. package/dist/channels/registry.js +33 -4
  13. package/dist/channels/telegram.d.ts +20 -5
  14. package/dist/channels/telegram.js +177 -42
  15. package/dist/channels/types.d.ts +11 -28
  16. package/dist/channels/voice-state.js +3 -1
  17. package/dist/channels/webhook.d.ts +1 -4
  18. package/dist/channels/webhook.js +26 -11
  19. package/dist/channels/whatsapp.d.ts +8 -4
  20. package/dist/channels/whatsapp.js +65 -29
  21. package/dist/cli/capabilities.js +4 -4
  22. package/dist/cli/channel.js +16 -6
  23. package/dist/cli/commands.js +12 -9
  24. package/dist/cli/doctor.js +80 -25
  25. package/dist/cli/handoff.d.ts +7 -14
  26. package/dist/cli/handoff.js +9 -44
  27. package/dist/cli/mcp.js +5 -5
  28. package/dist/cli/media.js +21 -8
  29. package/dist/cli/setup.js +9 -8
  30. package/dist/cli/store.js +29 -12
  31. package/dist/config.js +5 -10
  32. package/dist/daemon.js +88 -38
  33. package/dist/dashboard/html.js +80 -12
  34. package/dist/dashboard/routes.js +143 -79
  35. package/dist/dashboard/server.js +5 -1
  36. package/dist/db/connection.d.ts +29 -0
  37. package/dist/db/connection.js +37 -0
  38. package/dist/db/index.js +30 -12
  39. package/dist/db/migrations.js +84 -28
  40. package/dist/delegation/manager.js +10 -4
  41. package/dist/index.js +39 -59
  42. package/dist/knowledge/manager.js +26 -12
  43. package/dist/mcp/handlers.js +126 -57
  44. package/dist/mcp/server.js +20 -10
  45. package/dist/mcp/tool-definitions.js +68 -20
  46. package/dist/mcp/validate.d.ts +23 -0
  47. package/dist/mcp/validate.js +65 -0
  48. package/dist/media/factory.js +18 -14
  49. package/dist/media/generators/dall-e.js +2 -2
  50. package/dist/media/generators/kling.js +4 -4
  51. package/dist/media/generators/lyria.js +1 -1
  52. package/dist/media/generators/nano-banana.d.ts +1 -1
  53. package/dist/media/generators/nano-banana.js +2 -2
  54. package/dist/media/generators/poll-util.js +4 -4
  55. package/dist/media/generators/recraft.js +3 -3
  56. package/dist/media/generators/runway.js +4 -4
  57. package/dist/media/generators/stable-diffusion.js +2 -2
  58. package/dist/media/generators/veo.js +1 -1
  59. package/dist/media/index.js +1 -1
  60. package/dist/media/store.d.ts +7 -0
  61. package/dist/media/store.js +18 -4
  62. package/dist/media/types.d.ts +22 -0
  63. package/dist/notifications/index.d.ts +2 -4
  64. package/dist/notifications/index.js +6 -19
  65. package/dist/notifications/ntfy.js +3 -3
  66. package/dist/observability/analytics.js +35 -13
  67. package/dist/projects/index.d.ts +1 -1
  68. package/dist/projects/index.js +1 -1
  69. package/dist/projects/manager.d.ts +0 -4
  70. package/dist/projects/manager.js +51 -28
  71. package/dist/projects/router.d.ts +2 -0
  72. package/dist/projects/router.js +70 -45
  73. package/dist/service/install.js +15 -5
  74. package/dist/service/windows.js +1 -1
  75. package/dist/session/budget-guard.d.ts +20 -0
  76. package/dist/session/budget-guard.js +31 -0
  77. package/dist/session/circuit-breaker.d.ts +5 -3
  78. package/dist/session/circuit-breaker.js +45 -20
  79. package/dist/session/context-compactor.d.ts +32 -0
  80. package/dist/session/context-compactor.js +45 -0
  81. package/dist/session/context-monitor.js +2 -2
  82. package/dist/session/handoff.d.ts +21 -0
  83. package/dist/session/handoff.js +50 -0
  84. package/dist/session/manager.d.ts +17 -5
  85. package/dist/session/manager.js +153 -146
  86. package/dist/session/memory-store.d.ts +29 -0
  87. package/dist/session/memory-store.js +45 -0
  88. package/dist/session/message-queue.d.ts +28 -0
  89. package/dist/session/message-queue.js +52 -0
  90. package/dist/session/pending-dispatcher.d.ts +31 -0
  91. package/dist/session/pending-dispatcher.js +120 -0
  92. package/dist/session/pending-store.d.ts +60 -0
  93. package/dist/session/pending-store.js +118 -0
  94. package/dist/session/stale-session.d.ts +31 -0
  95. package/dist/session/stale-session.js +45 -0
  96. package/dist/session/subprocess.d.ts +2 -0
  97. package/dist/session/subprocess.js +33 -11
  98. package/dist/session/tab-store.js +4 -3
  99. package/dist/tasks/scheduler.d.ts +7 -0
  100. package/dist/tasks/scheduler.js +46 -6
  101. package/dist/tasks/store.js +20 -6
  102. package/dist/timeline/logger.js +3 -1
  103. package/dist/timeline/query.js +9 -3
  104. package/dist/types.d.ts +34 -9
  105. package/dist/util/auto-heal.js +15 -5
  106. package/dist/util/install-info.js +3 -1
  107. package/dist/util/logger.d.ts +1 -1
  108. package/dist/util/logger.js +63 -24
  109. package/dist/util/paths.d.ts +1 -0
  110. package/dist/util/paths.js +12 -2
  111. package/dist/util/retry.js +1 -1
  112. package/dist/util/text.js +13 -7
  113. package/dist/voice/index.js +5 -1
  114. package/dist/voice/stt.js +14 -6
  115. package/dist/voice/tts.js +1 -1
  116. package/dist/watchers/scheduler.js +9 -2
  117. package/package.json +6 -1
  118. package/dist/session/tool-classifier.d.ts +0 -4
  119. package/dist/session/tool-classifier.js +0 -56
@@ -1,3 +1,3 @@
1
1
  export type { CapabilityPack, EnabledCapability } from './types.js';
2
2
  export { CAPABILITY_PACKS } from './packs.js';
3
- export { getAvailablePacks, getEnabledCapabilities, isEnabled, enablePack, disablePack, updateMcpConfig } from './manager.js';
3
+ export { getAvailablePacks, getEnabledCapabilities, isEnabled, enablePack, disablePack, updateMcpConfig, } from './manager.js';
@@ -1,2 +1,2 @@
1
1
  export { CAPABILITY_PACKS } from './packs.js';
2
- export { getAvailablePacks, getEnabledCapabilities, isEnabled, enablePack, disablePack, updateMcpConfig } from './manager.js';
2
+ export { getAvailablePacks, getEnabledCapabilities, isEnabled, enablePack, disablePack, updateMcpConfig, } from './manager.js';
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
- import { execSync } from 'node:child_process';
2
+ import { execFileSync } from 'node:child_process';
3
3
  import { getConfig, saveConfig } from '../config.js';
4
+ const SAFE_NPM_PACKAGE = /^[@a-zA-Z0-9_/.-]+$/;
4
5
  import { getMcpConfigPath } from '../util/paths.js';
5
6
  import { logger } from '../util/logger.js';
6
7
  import { CAPABILITY_PACKS } from './packs.js';
@@ -15,28 +16,31 @@ export function getEnabledCapabilities() {
15
16
  }
16
17
  /** Check if a pack is enabled */
17
18
  export function isEnabled(packId) {
18
- return getEnabledCapabilities().some(c => c.packId === packId);
19
+ return getEnabledCapabilities().some((c) => c.packId === packId);
19
20
  }
20
21
  /** Enable a capability pack */
21
22
  export function enablePack(packId, apiKey) {
22
- const pack = CAPABILITY_PACKS.find(p => p.id === packId);
23
+ const pack = CAPABILITY_PACKS.find((p) => p.id === packId);
23
24
  if (!pack)
24
25
  throw new Error(`Unknown capability: ${packId}. Use 'beecork capabilities' to list.`);
25
26
  if (pack.requiresApiKey && !apiKey) {
26
27
  throw new Error(`${pack.name} requires an API key. Hint: ${pack.apiKeyHint}`);
27
28
  }
28
29
  // Install the MCP server package
30
+ if (!SAFE_NPM_PACKAGE.test(pack.mcpServer.package)) {
31
+ throw new Error(`Invalid package name in capability pack: ${pack.mcpServer.package}`);
32
+ }
29
33
  try {
30
34
  console.log(`Installing ${pack.mcpServer.package}...`);
31
- execSync(`npm install -g ${pack.mcpServer.package}`, { stdio: 'pipe' });
35
+ execFileSync('npm', ['install', '-g', pack.mcpServer.package], { stdio: 'pipe' });
32
36
  }
33
- catch (err) {
37
+ catch {
34
38
  logger.warn(`Package install skipped (may already be available via npx): ${pack.mcpServer.package}`);
35
39
  }
36
40
  // Update config
37
41
  const config = getConfig();
38
42
  const capabilities = config.capabilities || [];
39
- const existing = capabilities.findIndex(c => c.packId === packId);
43
+ const existing = capabilities.findIndex((c) => c.packId === packId);
40
44
  const entry = { packId, apiKey, enabledAt: new Date().toISOString() };
41
45
  if (existing >= 0) {
42
46
  capabilities[existing] = entry;
@@ -54,7 +58,7 @@ export function enablePack(packId, apiKey) {
54
58
  export function disablePack(packId) {
55
59
  const config = getConfig();
56
60
  const capabilities = config.capabilities || [];
57
- config.capabilities = capabilities.filter(c => c.packId !== packId);
61
+ config.capabilities = capabilities.filter((c) => c.packId !== packId);
58
62
  saveConfig(config);
59
63
  updateMcpConfig();
60
64
  logger.info(`Capability disabled: ${packId}`);
@@ -78,7 +82,7 @@ export function updateMcpConfig() {
78
82
  // Add enabled capability servers
79
83
  const capabilities = getEnabledCapabilities();
80
84
  for (const cap of capabilities) {
81
- const pack = CAPABILITY_PACKS.find(p => p.id === cap.packId);
85
+ const pack = CAPABILITY_PACKS.find((p) => p.id === cap.packId);
82
86
  if (!pack)
83
87
  continue;
84
88
  // Resolve env vars
@@ -96,7 +100,7 @@ export function updateMcpConfig() {
96
100
  env[pack.apiKeyEnvVar] = cap.apiKey;
97
101
  }
98
102
  // Resolve args templates
99
- const args = (pack.mcpServer.args || []).map(arg => arg.replace(/\$\{(\w+)\}/g, (_, varName) => {
103
+ const args = (pack.mcpServer.args || []).map((arg) => arg.replace(/\$\{(\w+)\}/g, (_, varName) => {
100
104
  if (varName === pack.apiKeyEnvVar)
101
105
  return cap.apiKey || '';
102
106
  return process.env[varName] || '';
@@ -9,7 +9,9 @@ export const CAPABILITY_PACKS = [
9
9
  package: '@notionhq/notion-mcp-server',
10
10
  command: 'npx',
11
11
  args: ['-y', '@notionhq/notion-mcp-server'],
12
- env: { OPENAPI_MCP_HEADERS: '{"Authorization":"Bearer ${NOTION_API_KEY}","Notion-Version":"2022-06-28"}' },
12
+ env: {
13
+ OPENAPI_MCP_HEADERS: '{"Authorization":"Bearer ${NOTION_API_KEY}","Notion-Version":"2022-06-28"}',
14
+ },
13
15
  },
14
16
  requiresApiKey: true,
15
17
  apiKeyHint: 'Notion integration token (from notion.so/my-integrations)',
@@ -4,6 +4,8 @@
4
4
  */
5
5
  import { timeAgo } from '../util/text.js';
6
6
  import { validateTabName } from '../config.js';
7
+ import { exportTab, formatHandoffInfo } from '../session/handoff.js';
8
+ import { logActivity } from '../timeline/index.js';
7
9
  /**
8
10
  * Handle shared commands that work identically across all channels.
9
11
  * Returns { handled: true, response } if a command was matched.
@@ -16,7 +18,9 @@ export async function handleSharedCommand(ctx, tabManager) {
16
18
  const tabs = tabManager.listTabs();
17
19
  if (tabs.length === 0)
18
20
  return { handled: true, response: 'No tabs.' };
19
- const list = tabs.map(t => `• ${t.name} [${t.status}] — ${timeAgo(t.lastActivityAt)}`).join('\n');
21
+ const list = tabs
22
+ .map((t) => `• ${t.name} [${t.status}] — ${timeAgo(t.lastActivityAt)}`)
23
+ .join('\n');
20
24
  return { handled: true, response: list };
21
25
  }
22
26
  // /stop <name>
@@ -71,10 +75,12 @@ export async function handleSharedCommand(ctx, tabManager) {
71
75
  const watchers = db.prepare('SELECT * FROM watchers ORDER BY created_at').all();
72
76
  if (watchers.length === 0)
73
77
  return { handled: true, response: 'No watchers configured.' };
74
- const watchList = watchers.map((w) => {
78
+ const watchList = watchers
79
+ .map((w) => {
75
80
  const status = w.enabled ? 'active' : 'disabled';
76
81
  return `[${status}] ${w.name} -- ${w.schedule} (triggers: ${w.trigger_count})`;
77
- }).join('\n');
82
+ })
83
+ .join('\n');
78
84
  return { handled: true, response: `Watchers:\n${watchList}` };
79
85
  }
80
86
  // /tasks
@@ -84,10 +90,12 @@ export async function handleSharedCommand(ctx, tabManager) {
84
90
  const tasks = db.prepare('SELECT * FROM tasks ORDER BY created_at').all();
85
91
  if (tasks.length === 0)
86
92
  return { handled: true, response: 'No tasks scheduled.' };
87
- const taskList = tasks.map((t) => {
93
+ const taskList = tasks
94
+ .map((t) => {
88
95
  const status = t.enabled ? 'enabled' : 'disabled';
89
96
  return `[${status}] ${t.name} (${t.schedule_type}: ${t.schedule}) -> tab:${t.tab_name}`;
90
- }).join('\n');
97
+ })
98
+ .join('\n');
91
99
  return { handled: true, response: `Tasks:\n${taskList}` };
92
100
  }
93
101
  // /cost
@@ -104,14 +112,17 @@ export async function handleSharedCommand(ctx, tabManager) {
104
112
  // /handoff [tab]
105
113
  if (text.startsWith('/handoff')) {
106
114
  const tabName = text.slice(9).trim() || 'default';
107
- const { exportTab, formatHandoffInfo } = await import('../cli/handoff.js');
108
115
  const info = exportTab(tabName);
109
116
  if (!info)
110
117
  return { handled: true, response: `Tab "${tabName}" not found.` };
118
+ logActivity('user_command', '/handoff', { tabName });
111
119
  return { handled: true, response: formatHandoffInfo(info) };
112
120
  }
113
121
  // /folders (also accept legacy /projects)
114
- if (text === '/folders' || text === '/projects' || text.startsWith('/folders@') || text.startsWith('/projects@')) {
122
+ if (text === '/folders' ||
123
+ text === '/projects' ||
124
+ text.startsWith('/folders@') ||
125
+ text.startsWith('/projects@')) {
115
126
  const { listProjects } = await import('../projects/index.js');
116
127
  const projects = listProjects();
117
128
  if (projects.length === 0)
@@ -128,15 +139,22 @@ export async function handleSharedCommand(ctx, tabManager) {
128
139
  return { handled: true, response: msg };
129
140
  }
130
141
  // /folder <name> (also accept legacy /project <name>)
131
- if ((text.startsWith('/folder ') && !text.startsWith('/folders')) || (text.startsWith('/project ') && !text.startsWith('/projects'))) {
142
+ if ((text.startsWith('/folder ') && !text.startsWith('/folders')) ||
143
+ (text.startsWith('/project ') && !text.startsWith('/projects'))) {
132
144
  const prefix = text.startsWith('/folder ') ? '/folder ' : '/project ';
133
145
  const name = text.slice(prefix.length).trim();
134
146
  const { getProject, setUserContext } = await import('../projects/index.js');
135
147
  const project = getProject(name);
136
148
  if (!project)
137
- return { handled: true, response: `Folder "${name}" not found. Use /folders to list or /newfolder to create.` };
149
+ return {
150
+ handled: true,
151
+ response: `Folder "${name}" not found. Use /folders to list or /newfolder to create.`,
152
+ };
138
153
  setUserContext(userId, project.name, project.name);
139
- return { handled: true, response: `Switched to folder: ${project.name}\nPath: ${project.path}\n\nNext messages will work in this folder.` };
154
+ return {
155
+ handled: true,
156
+ response: `Switched to folder: ${project.name}\nPath: ${project.path}\n\nNext messages will work in this folder.`,
157
+ };
140
158
  }
141
159
  // /newfolder <name> [path] (also accept legacy /newproject)
142
160
  if (text.startsWith('/newfolder ') || text.startsWith('/newproject ')) {
@@ -149,7 +167,10 @@ export async function handleSharedCommand(ctx, tabManager) {
149
167
  const { createProject, setUserContext } = await import('../projects/index.js');
150
168
  const project = createProject(name, customPath);
151
169
  setUserContext(userId, project.name, project.name);
152
- return { handled: true, response: `✓ Folder "${name}" created at ${project.path}\nSwitched to this folder.` };
170
+ return {
171
+ handled: true,
172
+ response: `✓ Folder "${name}" created at ${project.path}\nSwitched to this folder.`,
173
+ };
153
174
  }
154
175
  // /close <tab>
155
176
  if (text.startsWith('/close ')) {
@@ -157,7 +178,12 @@ export async function handleSharedCommand(ctx, tabManager) {
157
178
  if (!tabNameToClose)
158
179
  return { handled: true, response: 'Usage: /close <tabname>' };
159
180
  const closed = tabManager.closeTab(tabNameToClose);
160
- return { handled: true, response: closed ? `Tab "${tabNameToClose}" permanently closed. History deleted.` : `Tab "${tabNameToClose}" not found.` };
181
+ return {
182
+ handled: true,
183
+ response: closed
184
+ ? `Tab "${tabNameToClose}" permanently closed. History deleted.`
185
+ : `Tab "${tabNameToClose}" not found.`,
186
+ };
161
187
  }
162
188
  // /fresh <folder>
163
189
  if (text.startsWith('/fresh ')) {
@@ -168,7 +194,10 @@ export async function handleSharedCommand(ctx, tabManager) {
168
194
  return { handled: true, response: `Folder "${folderName}" not found.` };
169
195
  const freshTabName = `${folderName}-${Date.now().toString(36).slice(-4)}`;
170
196
  setUserContext(userId, project.name, freshTabName);
171
- return { handled: true, response: `Fresh start in "${folderName}" (tab: ${freshTabName})\nSend your message now.` };
197
+ return {
198
+ handled: true,
199
+ response: `Fresh start in "${folderName}" (tab: ${freshTabName})\nSend your message now.`,
200
+ };
172
201
  }
173
202
  // /history [date|yesterday]
174
203
  if (text === '/history' || text.startsWith('/history ')) {
@@ -192,7 +221,10 @@ export async function handleSharedCommand(ctx, tabManager) {
192
221
  const { getAllKnowledge, formatKnowledgeForContext } = await import('../knowledge/index.js');
193
222
  const entries = getAllKnowledge();
194
223
  if (entries.length === 0) {
195
- return { handled: true, response: 'No knowledge stored yet. Beecork learns from your conversations.' };
224
+ return {
225
+ handled: true,
226
+ response: 'No knowledge stored yet. Beecork learns from your conversations.',
227
+ };
196
228
  }
197
229
  return { handled: true, response: formatKnowledgeForContext(entries).slice(0, 4000) };
198
230
  }
@@ -1,10 +1,8 @@
1
- import type { Channel, ChannelContext, InboundMessageHandler, SendOptions } from './types.js';
1
+ import type { Channel, ChannelContext, SendOptions } from './types.js';
2
2
  export declare class DiscordChannel implements Channel {
3
3
  readonly id = "discord";
4
4
  readonly name = "Discord";
5
5
  readonly maxMessageLength = 2000;
6
- readonly supportsStreaming = false;
7
- readonly supportsMedia = true;
8
6
  private client;
9
7
  private ctx;
10
8
  private allowedUserIds;
@@ -12,9 +10,8 @@ export declare class DiscordChannel implements Channel {
12
10
  constructor(ctx: ChannelContext);
13
11
  start(): Promise<void>;
14
12
  stop(): void;
15
- onMessage(_handler: InboundMessageHandler): void;
16
- sendMessage(peerId: string, text: string, options?: SendOptions): Promise<void>;
17
- sendNotification(message: string, urgent?: boolean): Promise<void>;
13
+ sendMessage(peerId: string, text: string, _options?: SendOptions): Promise<void>;
14
+ sendNotification(message: string, _urgent?: boolean): Promise<void>;
18
15
  setTyping(peerId: string, active: boolean): Promise<void>;
19
16
  private sendResponse;
20
17
  }
@@ -1,6 +1,17 @@
1
+ /*
2
+ * Discord integration via discord.js (peer-optional, dynamic-imported).
3
+ *
4
+ * discord.js's strict union types (PartialGroupDMChannel, TextChannel, etc.)
5
+ * require narrowing at every send/sendTyping callsite. The runtime channel
6
+ * objects we receive in handlers all support these methods, but expressing
7
+ * that to TypeScript via the published types is a significant refactor with
8
+ * no runtime benefit. We accept `any` at this library boundary — same pattern
9
+ * as the WhatsApp/baileys integration.
10
+ */
11
+ /* eslint-disable @typescript-eslint/no-explicit-any */
1
12
  import { logger } from '../util/logger.js';
2
- import { chunkText, formatTabbedResponse, parseTabMessage } from '../util/text.js';
3
- import { retryWithBackoff } from '../util/retry.js';
13
+ import { parseTabMessage } from '../util/text.js';
14
+ import { sendChunkedResponse } from './send-helpers.js';
4
15
  import { inboundLimiter } from '../util/rate-limiter.js';
5
16
  import { saveMedia, isOversized } from '../media/store.js';
6
17
  import { VoiceState } from './voice-state.js';
@@ -10,8 +21,6 @@ export class DiscordChannel {
10
21
  id = 'discord';
11
22
  name = 'Discord';
12
23
  maxMessageLength = 2000;
13
- supportsStreaming = false; // Discord message editing is rate-limited
14
- supportsMedia = true;
15
24
  client = null; // Discord.js Client
16
25
  ctx;
17
26
  allowedUserIds;
@@ -56,7 +65,9 @@ export class DiscordChannel {
56
65
  }
57
66
  // Rate limit
58
67
  if (!inboundLimiter.check(this.id)) {
59
- await message.reply("I'm receiving too many messages right now. Please wait a moment.").catch(() => { });
68
+ await message
69
+ .reply("I'm receiving too many messages right now. Please wait a moment.")
70
+ .catch(() => { });
60
71
  return;
61
72
  }
62
73
  const text = message.content
@@ -188,26 +199,25 @@ export class DiscordChannel {
188
199
  }
189
200
  logger.info('Discord bot stopped');
190
201
  }
191
- onMessage(_handler) {
192
- // Messages are handled directly in start()
193
- }
194
- async sendMessage(peerId, text, options) {
202
+ async sendMessage(peerId, text, _options) {
195
203
  if (!this.client)
196
204
  return;
197
205
  try {
198
206
  const channel = await this.client.channels.fetch(peerId);
199
207
  if (!channel?.isTextBased())
200
208
  return;
201
- const chunks = chunkText(text, this.maxMessageLength);
202
- for (const chunk of chunks) {
203
- await retryWithBackoff(() => channel.send(chunk), [1000, 5000, 15000], 'discord-send');
204
- }
209
+ await sendChunkedResponse({
210
+ text,
211
+ maxLength: this.maxMessageLength,
212
+ retryLabel: 'discord-send',
213
+ sendChunk: (chunk) => channel.send(chunk),
214
+ });
205
215
  }
206
216
  catch (err) {
207
217
  logger.error(`Discord send failed for ${peerId}:`, err);
208
218
  }
209
219
  }
210
- async sendNotification(message, urgent) {
220
+ async sendNotification(message, _urgent) {
211
221
  if (!this.client)
212
222
  return;
213
223
  // Send to all allowed users via DM
@@ -236,14 +246,21 @@ export class DiscordChannel {
236
246
  }
237
247
  async sendResponse(message, text, tabName) {
238
248
  // 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
- }
249
+ // original user message; follow-ups use channel.send. sendChunkedResponse
250
+ // handles prefix + chunking + retry envelope; the closure-tracked chunkIdx
251
+ // keeps the "first chunk replies, rest send" behavior.
252
+ let chunkIdx = 0;
253
+ await sendChunkedResponse({
254
+ text,
255
+ tabName,
256
+ maxLength: this.maxMessageLength,
257
+ retryDelays: [1000, 5000],
258
+ retryLabel: 'discord-send',
259
+ sendChunk: (chunk) => {
260
+ const isFirst = chunkIdx === 0;
261
+ chunkIdx++;
262
+ return isFirst ? message.reply(chunk) : message.channel.send(chunk);
263
+ },
264
+ });
248
265
  }
249
266
  }
@@ -4,4 +4,4 @@ export { WhatsAppChannel } from './whatsapp.js';
4
4
  export { WebhookChannel } from './webhook.js';
5
5
  export { DiscordChannel } from './discord.js';
6
6
  export { loadCommunityChannels } from './loader.js';
7
- export type { Channel, ChannelContext, InboundMessage, InboundMessageHandler, MediaAttachment, SendOptions } from './types.js';
7
+ export type { Channel, ChannelContext, MediaAttachment, SendOptions } from './types.js';
@@ -47,8 +47,15 @@ export async function loadCommunityChannels(ctx) {
47
47
  logger.warn(`Community channel ${dir}: entry point not found at ${entryPath}`);
48
48
  continue;
49
49
  }
50
- // Dynamic import — community channels run with full daemon access
51
- logger.warn(`Loading community channel from ${dir} ensure you trust this package`);
50
+ // Dynamic import — community channels run with full daemon access:
51
+ // they can read every config token (Telegram, Discord, WhatsApp,
52
+ // notification provider keys) AND every environment variable the
53
+ // daemon inherits (including SSH-agent sockets, GitHub tokens,
54
+ // anything sourced from your shell profile). A compromised
55
+ // community-channel package is effectively a root credential drop.
56
+ // Pin versions in package.json, audit before opting in, and treat
57
+ // every entry in `communityChannels` as a trust anchor.
58
+ logger.warn(`Loading community channel "${dir}" — this package will have FULL access to your channel tokens (Telegram/Discord/WhatsApp) and every environment variable inherited by the daemon (including SSH keys). Only enable if you trust the package author and have audited the version.`);
52
59
  const module = await import(entryPath);
53
60
  const ChannelClass = module.default || module[Object.keys(module)[0]];
54
61
  if (!ChannelClass || typeof ChannelClass !== 'function') {
@@ -57,7 +64,10 @@ export async function loadCommunityChannels(ctx) {
57
64
  }
58
65
  const instance = new ChannelClass(ctx);
59
66
  // Validate it implements Channel interface (duck typing)
60
- if (!instance.id || !instance.name || typeof instance.start !== 'function' || typeof instance.stop !== 'function') {
67
+ if (!instance.id ||
68
+ !instance.name ||
69
+ typeof instance.start !== 'function' ||
70
+ typeof instance.stop !== 'function') {
61
71
  logger.warn(`Community channel ${dir}: does not implement Channel interface`);
62
72
  continue;
63
73
  }
@@ -22,9 +22,20 @@ import { logger } from '../util/logger.js';
22
22
  export async function processInboundMessage(opts) {
23
23
  const { text, media, channelId, tabManager, voiceReplyMode, ttsProvider, userId, sendProgress, overrideTabName, onTextChunk, } = opts;
24
24
  // 1. Parse tab name and prompt from the message text
25
- let { tabName, prompt: rawPrompt } = parseTabMessage(text);
26
- // Allow channel to override tab name (group tabs, thread tabs, etc.)
25
+ const parsed = parseTabMessage(text);
26
+ const rawPrompt = parsed.prompt;
27
+ let tabName = parsed.tabName;
28
+ // Allow channel to override tab name (group tabs, thread tabs, etc.).
29
+ // Validate centrally so each channel doesn't need its own check — keeps the
30
+ // CLAUDE.md "validation is centralized" rule honest. `default` is allowed.
27
31
  if (overrideTabName) {
32
+ if (overrideTabName !== 'default') {
33
+ const { validateTabName } = await import('../config.js');
34
+ const overrideErr = validateTabName(overrideTabName);
35
+ if (overrideErr) {
36
+ return { responseText: `Invalid tab name: ${overrideErr}`, tabName, isError: true };
37
+ }
38
+ }
28
39
  tabName = overrideTabName;
29
40
  }
30
41
  if (!rawPrompt && media.length === 0) {
@@ -56,9 +67,7 @@ export async function processInboundMessage(opts) {
56
67
  progressTracker.stop();
57
68
  // 6. Build response text
58
69
  const isError = result.error;
59
- const responseText = isError
60
- ? `Error: ${result.text}`
61
- : result.text || '(empty response)';
70
+ const responseText = isError ? `Error: ${result.text}` : result.text || '(empty response)';
62
71
  // 7. TTS voice reply (shared logic)
63
72
  let audioPath;
64
73
  let voiceOnly = false;
@@ -1,4 +1,5 @@
1
1
  import type { Channel } from './types.js';
2
+ import type { MediaAttachment } from '../types.js';
2
3
  /**
3
4
  * ChannelRegistry manages the lifecycle of all channels.
4
5
  * Channels register themselves, and the registry handles start/stop/broadcast.
@@ -7,7 +8,12 @@ export declare class ChannelRegistry {
7
8
  private channels;
8
9
  /** Register a channel */
9
10
  register(channel: Channel): void;
10
- /** Start all registered channels */
11
+ /**
12
+ * Start all registered channels in parallel. Each channel's start() is an
13
+ * independent network handshake (Telegram getMe, Discord login, WhatsApp
14
+ * Baileys boot) — running them sequentially adds 2-5s to cold start with no
15
+ * coordination benefit.
16
+ */
11
17
  start(): Promise<void>;
12
18
  /** Stop all channels */
13
19
  stop(): void;
@@ -17,4 +23,14 @@ export declare class ChannelRegistry {
17
23
  getAll(): Channel[];
18
24
  /** Broadcast a notification to all channels */
19
25
  broadcastNotify(text: string, urgent?: boolean): Promise<void>;
26
+ /**
27
+ * Broadcast a media attachment to every channel that supports it. Used by
28
+ * the pending-message dispatcher to deliver media queued by
29
+ * `beecork_send_media` and the `beecork_generate_*` tools — without this,
30
+ * those tools queue a row whose JSON envelope is never rendered.
31
+ *
32
+ * Channels that don't implement broadcastMedia get a sendNotification
33
+ * fallback so the user at least learns a file was generated.
34
+ */
35
+ broadcastMedia(media: MediaAttachment): Promise<void>;
20
36
  }
@@ -13,9 +13,14 @@ export class ChannelRegistry {
13
13
  this.channels.set(channel.id, channel);
14
14
  logger.info(`Channel registered: ${channel.name} (${channel.id})`);
15
15
  }
16
- /** Start all registered channels */
16
+ /**
17
+ * Start all registered channels in parallel. Each channel's start() is an
18
+ * independent network handshake (Telegram getMe, Discord login, WhatsApp
19
+ * Baileys boot) — running them sequentially adds 2-5s to cold start with no
20
+ * coordination benefit.
21
+ */
17
22
  async start() {
18
- for (const [id, channel] of this.channels) {
23
+ await Promise.all(Array.from(this.channels.entries()).map(async ([id, channel]) => {
19
24
  try {
20
25
  await channel.start();
21
26
  logger.info(`Channel started: ${channel.name}`);
@@ -23,7 +28,7 @@ export class ChannelRegistry {
23
28
  catch (err) {
24
29
  logger.error(`Failed to start channel ${id}:`, err);
25
30
  }
26
- }
31
+ }));
27
32
  }
28
33
  /** Stop all channels */
29
34
  stop() {
@@ -46,6 +51,30 @@ export class ChannelRegistry {
46
51
  }
47
52
  /** Broadcast a notification to all channels */
48
53
  async broadcastNotify(text, urgent) {
49
- await Promise.all(Array.from(this.channels.values()).map(channel => channel.sendNotification(text, urgent).catch(err => logger.warn(`${channel.name} notify failed:`, err))));
54
+ await Promise.all(Array.from(this.channels.values()).map((channel) => channel
55
+ .sendNotification(text, urgent)
56
+ .catch((err) => logger.warn(`${channel.name} notify failed:`, err))));
57
+ }
58
+ /**
59
+ * Broadcast a media attachment to every channel that supports it. Used by
60
+ * the pending-message dispatcher to deliver media queued by
61
+ * `beecork_send_media` and the `beecork_generate_*` tools — without this,
62
+ * those tools queue a row whose JSON envelope is never rendered.
63
+ *
64
+ * Channels that don't implement broadcastMedia get a sendNotification
65
+ * fallback so the user at least learns a file was generated.
66
+ */
67
+ async broadcastMedia(media) {
68
+ await Promise.all(Array.from(this.channels.values()).map((channel) => {
69
+ if (channel.broadcastMedia) {
70
+ return channel
71
+ .broadcastMedia(media)
72
+ .catch((err) => logger.warn(`${channel.name} broadcastMedia failed:`, err));
73
+ }
74
+ const fallback = `📎 Media generated: ${media.filePath}${media.caption ? `\n${media.caption}` : ''}`;
75
+ return channel
76
+ .sendNotification(fallback)
77
+ .catch((err) => logger.warn(`${channel.name} media-fallback notify failed:`, err));
78
+ }));
50
79
  }
51
80
  }
@@ -1,30 +1,45 @@
1
- import type { Channel, ChannelContext, InboundMessageHandler, SendOptions } from './types.js';
1
+ import type { Channel, ChannelContext, MediaAttachment, SendOptions } from './types.js';
2
2
  export declare class TelegramChannel implements Channel {
3
3
  readonly id = "telegram";
4
4
  readonly name = "Telegram";
5
5
  readonly maxMessageLength = 4096;
6
- readonly supportsStreaming = true;
7
- readonly supportsMedia = true;
8
6
  private bot;
9
7
  private ctx;
10
8
  private activeChatIds;
9
+ private allowedUserIdSet;
11
10
  private voice;
12
11
  private botUserId;
13
12
  private botUsername;
14
13
  private mutedGroups;
15
14
  private welcomeSent;
15
+ private pollingErrorTimes;
16
+ private pollingDegradedNotified;
16
17
  constructor(ctx: ChannelContext);
17
18
  start(): Promise<void>;
19
+ /**
20
+ * Track polling errors over a 60s rolling window. If we see 5+ errors in 60s
21
+ * we surface "polling degraded" exactly once via the notify callback so the
22
+ * user knows Telegram is broken instead of silently failing.
23
+ */
24
+ private recordPollingError;
18
25
  stop(): void;
19
- sendMessage(peerId: string, text: string, options?: SendOptions): Promise<void>;
26
+ sendMessage(peerId: string, text: string, _options?: SendOptions): Promise<void>;
20
27
  sendNotification(message: string, _urgent?: boolean): Promise<void>;
28
+ /**
29
+ * Send a media file to every recipient the bot would notify. Used by the
30
+ * pending-message dispatcher to deliver media queued by MCP tools.
31
+ * Routes by attachment type to the right Telegram primitive (sendVoice for
32
+ * voice messages, sendPhoto for images, sendVideo for video, sendDocument
33
+ * for everything else).
34
+ */
35
+ broadcastMedia(media: MediaAttachment): Promise<void>;
21
36
  setTyping(peerId: string, active: boolean): Promise<void>;
22
- onMessage(_handler: InboundMessageHandler): void;
23
37
  private setupHandlers;
24
38
  private handleCommand;
25
39
  private handleMessage;
26
40
  private sendResponse;
27
41
  private sendWithRetry;
42
+ private sendWithRetryRaw;
28
43
  private downloadTelegramFile;
29
44
  private isAllowed;
30
45
  private isAdmin;