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