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.
- package/dist/channels/admin.d.ts +10 -0
- package/dist/channels/admin.js +20 -0
- package/dist/channels/command-handler.d.ts +2 -10
- package/dist/channels/command-handler.js +47 -73
- package/dist/channels/discord.d.ts +1 -3
- package/dist/channels/discord.js +28 -28
- package/dist/channels/loader.js +0 -1
- package/dist/channels/send-helpers.d.ts +19 -0
- package/dist/channels/send-helpers.js +21 -0
- package/dist/channels/telegram.d.ts +1 -9
- package/dist/channels/telegram.js +46 -71
- package/dist/channels/types.d.ts +2 -10
- package/dist/channels/voice-state.d.ts +29 -0
- package/dist/channels/voice-state.js +43 -0
- package/dist/channels/webhook.d.ts +1 -1
- package/dist/channels/webhook.js +68 -24
- package/dist/channels/whatsapp.d.ts +1 -3
- package/dist/channels/whatsapp.js +79 -74
- package/dist/cli/doctor.js +5 -2
- package/dist/cli/handoff.js +6 -6
- package/dist/config.d.ts +5 -1
- package/dist/config.js +17 -14
- package/dist/daemon.js +29 -17
- package/dist/dashboard/html.js +20 -8
- package/dist/dashboard/routes.d.ts +17 -0
- package/dist/dashboard/routes.js +559 -0
- package/dist/dashboard/server.js +33 -488
- package/dist/db/index.js +16 -2
- package/dist/db/migrations.js +44 -8
- package/dist/mcp/handlers.d.ts +37 -0
- package/dist/mcp/handlers.js +451 -0
- package/dist/mcp/server.js +25 -849
- package/dist/mcp/tool-definitions.d.ts +1225 -0
- package/dist/mcp/tool-definitions.js +364 -0
- package/dist/media/index.d.ts +2 -7
- package/dist/media/index.js +1 -1
- package/dist/observability/analytics.d.ts +1 -1
- package/dist/observability/analytics.js +6 -3
- package/dist/projects/index.d.ts +3 -2
- package/dist/projects/index.js +2 -2
- package/dist/projects/manager.d.ts +1 -3
- package/dist/projects/manager.js +26 -25
- package/dist/projects/router.d.ts +10 -0
- package/dist/projects/router.js +28 -0
- package/dist/session/manager.d.ts +4 -0
- package/dist/session/manager.js +48 -42
- package/dist/session/subprocess.d.ts +1 -0
- package/dist/session/subprocess.js +21 -0
- package/dist/session/tab-store.d.ts +28 -0
- package/dist/session/tab-store.js +77 -0
- package/dist/tasks/scheduler.d.ts +6 -0
- package/dist/tasks/scheduler.js +52 -13
- package/dist/tasks/store.js +6 -6
- package/dist/timeline/query.js +6 -2
- package/dist/types.d.ts +15 -0
- package/dist/util/paths.d.ts +1 -0
- package/dist/util/paths.js +4 -1
- package/dist/util/rate-limiter.js +8 -0
- package/dist/util/text.d.ts +21 -1
- package/dist/util/text.js +25 -1
- package/dist/watchers/scheduler.js +2 -3
- package/package.json +1 -1
- package/dist/users/index.d.ts +0 -2
- package/dist/users/index.js +0 -1
- package/dist/users/service.d.ts +0 -17
- 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
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return {
|
|
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
|
|
12
|
-
private ttsProvider;
|
|
13
|
-
private sttWarmedUp;
|
|
11
|
+
private voice;
|
|
14
12
|
constructor(ctx: ChannelContext);
|
|
15
13
|
start(): Promise<void>;
|
|
16
14
|
stop(): void;
|
package/dist/channels/discord.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
this.
|
|
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
|
|
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
|
|
126
|
+
const sanitized = (message.channel.name || '')
|
|
131
127
|
.replace(/[^a-zA-Z0-9-]/g, '-')
|
|
132
128
|
.replace(/^-+|-+$/g, '')
|
|
133
129
|
.slice(0, 32);
|
|
134
|
-
|
|
135
|
-
|
|
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.
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
}
|
package/dist/channels/loader.js
CHANGED
|
@@ -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
|
|
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,
|
|
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 {
|
|
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
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
142
|
-
'\u2022
|
|
143
|
-
'\u2022
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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.
|
|
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
|
|
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(
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
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
|
-
|
|
518
|
-
|
|
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 {
|