fix(runtime): keep daemon alive and localize package installs

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Harivansh Rathi 2026-03-05 17:36:25 -08:00
parent fa208bca73
commit 3f04822f58
38 changed files with 2051 additions and 1939 deletions

View file

@ -39,13 +39,8 @@
import { SocketModeClient } from "@slack/socket-mode";
import { WebClient } from "@slack/web-api";
import type {
ChannelAdapter,
ChannelMessage,
AdapterConfig,
OnIncomingMessage,
} from "../types.ts";
import { getChannelSetting } from "../config.ts";
import type { AdapterConfig, ChannelAdapter, ChannelMessage, OnIncomingMessage } from "../types.ts";
const MAX_LENGTH = 3000; // Slack block text limit; actual API limit is 4000 but leave margin
@ -88,17 +83,17 @@ export type SlackAdapterLogger = (event: string, data: Record<string, unknown>,
export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: SlackAdapterLogger): ChannelAdapter {
// Tokens live in settings under pi-channels.slack (not in the adapter config block)
const appToken = (cwd ? getChannelSetting(cwd, "slack.appToken") as string : null)
?? config.appToken as string;
const botToken = (cwd ? getChannelSetting(cwd, "slack.botToken") as string : null)
?? config.botToken as string;
const appToken = (cwd ? (getChannelSetting(cwd, "slack.appToken") as string) : null) ?? (config.appToken as string);
const botToken = (cwd ? (getChannelSetting(cwd, "slack.botToken") as string) : null) ?? (config.botToken as string);
const allowedChannelIds = config.allowedChannelIds as string[] | undefined;
const respondToMentionsOnly = config.respondToMentionsOnly === true;
const slashCommand = (config.slashCommand as string) ?? "/aivena";
if (!appToken) throw new Error("Slack adapter requires appToken (xapp-...) in settings under pi-channels.slack.appToken");
if (!botToken) throw new Error("Slack adapter requires botToken (xoxb-...) in settings under pi-channels.slack.botToken");
if (!appToken)
throw new Error("Slack adapter requires appToken (xapp-...) in settings under pi-channels.slack.appToken");
if (!botToken)
throw new Error("Slack adapter requires botToken (xoxb-...) in settings under pi-channels.slack.botToken");
let socketClient: SocketModeClient | null = null;
const webClient = new WebClient(botToken);
@ -119,7 +114,10 @@ export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: Sl
}
/** Build metadata common to all incoming messages */
function buildMetadata(event: { channel?: string; user?: string; ts?: string; thread_ts?: string; channel_type?: string }, extra?: Record<string, unknown>): Record<string, unknown> {
function buildMetadata(
event: { channel?: string; user?: string; ts?: string; thread_ts?: string; channel_type?: string },
extra?: Record<string, unknown>,
): Record<string, unknown> {
return {
channelId: event.channel,
userId: event.user,
@ -184,7 +182,7 @@ export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: Sl
// Resolve bot user ID (for stripping self-mentions)
try {
const authResult = await webClient.auth.test();
botUserId = authResult.user_id as string ?? null;
botUserId = (authResult.user_id as string) ?? null;
} catch {
// Non-fatal — mention stripping just won't work
}
@ -215,16 +213,20 @@ export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: Sl
// handled by the app_mention listener to avoid duplicate responses.
// DMs (im) and multi-party DMs (mpim) don't fire app_mention, so we
// must NOT skip those here.
if (botUserId && (event.channel_type === "channel" || event.channel_type === "group") && event.text.includes(`<@${botUserId}>`)) return;
if (
botUserId &&
(event.channel_type === "channel" || event.channel_type === "group") &&
event.text.includes(`<@${botUserId}>`)
)
return;
// In channels/groups, optionally only respond to @mentions
// (app_mention events are handled separately below)
if (respondToMentionsOnly && (event.channel_type === "channel" || event.channel_type === "group")) return;
if (respondToMentionsOnly && (event.channel_type === "channel" || event.channel_type === "group"))
return;
// Use channel:threadTs as sender key for threaded conversations
const sender = event.thread_ts
? `${event.channel}:${event.thread_ts}`
: event.channel;
const sender = event.thread_ts ? `${event.channel}:${event.thread_ts}` : event.channel;
onMessage({
adapter: "slack",
@ -234,74 +236,86 @@ export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: Sl
eventType: "message",
}),
});
} catch (err) { log?.("slack-handler-error", { handler: "message", error: String(err) }, "ERROR"); }
} catch (err) {
log?.("slack-handler-error", { handler: "message", error: String(err) }, "ERROR");
}
});
// ── App mention events ──────────────────────────
socketClient.on("app_mention", async ({ event, ack }: { event: SlackMentionEvent; ack: () => Promise<void> }) => {
try {
await ack();
socketClient.on(
"app_mention",
async ({ event, ack }: { event: SlackMentionEvent; ack: () => Promise<void> }) => {
try {
await ack();
if (!isAllowed(event.channel)) return;
if (!isAllowed(event.channel)) return;
const sender = event.thread_ts
? `${event.channel}:${event.thread_ts}`
: event.channel;
const sender = event.thread_ts ? `${event.channel}:${event.thread_ts}` : event.channel;
onMessage({
adapter: "slack",
sender,
text: stripBotMention(event.text),
metadata: buildMetadata(event, {
eventType: "app_mention",
}),
});
} catch (err) { log?.("slack-handler-error", { handler: "app_mention", error: String(err) }, "ERROR"); }
});
onMessage({
adapter: "slack",
sender,
text: stripBotMention(event.text),
metadata: buildMetadata(event, {
eventType: "app_mention",
}),
});
} catch (err) {
log?.("slack-handler-error", { handler: "app_mention", error: String(err) }, "ERROR");
}
},
);
// ── Slash commands ───────────────────────────────
socketClient.on("slash_commands", async ({ body, ack }: { body: SlackCommandPayload; ack: (response?: any) => Promise<void> }) => {
try {
if (body.command !== slashCommand) {
await ack();
return;
socketClient.on(
"slash_commands",
async ({ body, ack }: { body: SlackCommandPayload; ack: (response?: any) => Promise<void> }) => {
try {
if (body.command !== slashCommand) {
await ack();
return;
}
if (!body.text?.trim()) {
await ack({ text: `Usage: ${slashCommand} [your message]` });
return;
}
if (!isAllowed(body.channel_id)) {
await ack({ text: "⛔ This command is not available in this channel." });
return;
}
// Acknowledge immediately (Slack requires <3s response)
await ack({ text: "🤔 Thinking..." });
onMessage({
adapter: "slack",
sender: body.channel_id,
text: body.text.trim(),
metadata: {
channelId: body.channel_id,
channelName: body.channel_name,
userId: body.user_id,
userName: body.user_name,
eventType: "slash_command",
command: body.command,
},
});
} catch (err) {
log?.("slack-handler-error", { handler: "slash_commands", error: String(err) }, "ERROR");
}
if (!body.text?.trim()) {
await ack({ text: `Usage: ${slashCommand} [your message]` });
return;
}
if (!isAllowed(body.channel_id)) {
await ack({ text: "⛔ This command is not available in this channel." });
return;
}
// Acknowledge immediately (Slack requires <3s response)
await ack({ text: "🤔 Thinking..." });
onMessage({
adapter: "slack",
sender: body.channel_id,
text: body.text.trim(),
metadata: {
channelId: body.channel_id,
channelName: body.channel_name,
userId: body.user_id,
userName: body.user_name,
eventType: "slash_command",
command: body.command,
},
});
} catch (err) { log?.("slack-handler-error", { handler: "slash_commands", error: String(err) }, "ERROR"); }
});
},
);
// ── Interactive payloads (future: button clicks, modals) ──
socketClient.on("interactive", async ({ body, ack }: { body: any; ack: () => Promise<void> }) => {
try {
await ack();
// TODO: handle interactive payloads (block actions, modals)
} catch (err) { log?.("slack-handler-error", { handler: "interactive", error: String(err) }, "ERROR"); }
} catch (err) {
log?.("slack-handler-error", { handler: "interactive", error: String(err) }, "ERROR");
}
});
await socketClient.start();

View file

@ -26,15 +26,15 @@
*/
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import * as path from "node:path";
import type {
AdapterConfig,
ChannelAdapter,
ChannelMessage,
AdapterConfig,
OnIncomingMessage,
IncomingMessage,
IncomingAttachment,
IncomingMessage,
OnIncomingMessage,
TranscriptionConfig,
} from "../types.ts";
import { createTranscriptionProvider, type TranscriptionProvider } from "./transcription.ts";
@ -63,12 +63,49 @@ const TEXT_MIME_TYPES = new Set([
/** File extensions we treat as text even if MIME is generic (application/octet-stream). */
const TEXT_EXTENSIONS = new Set([
".md", ".markdown", ".txt", ".csv", ".json", ".jsonl", ".yaml", ".yml",
".toml", ".xml", ".html", ".htm", ".css", ".js", ".ts", ".tsx", ".jsx",
".py", ".rs", ".go", ".rb", ".php", ".java", ".kt", ".c", ".cpp", ".h",
".sh", ".bash", ".zsh", ".fish", ".sql", ".graphql", ".gql",
".env", ".ini", ".cfg", ".conf", ".properties", ".log",
".gitignore", ".dockerignore", ".editorconfig",
".md",
".markdown",
".txt",
".csv",
".json",
".jsonl",
".yaml",
".yml",
".toml",
".xml",
".html",
".htm",
".css",
".js",
".ts",
".tsx",
".jsx",
".py",
".rs",
".go",
".rb",
".php",
".java",
".kt",
".c",
".cpp",
".h",
".sh",
".bash",
".zsh",
".fish",
".sql",
".graphql",
".gql",
".env",
".ini",
".cfg",
".conf",
".properties",
".log",
".gitignore",
".dockerignore",
".editorconfig",
]);
/** Image MIME prefixes. */
@ -80,15 +117,22 @@ function isImageMime(mime: string | undefined): boolean {
/** Audio MIME types that can be transcribed. */
const AUDIO_MIME_PREFIXES = ["audio/"];
const AUDIO_MIME_TYPES = new Set([
"audio/mpeg", "audio/mp4", "audio/ogg", "audio/wav", "audio/webm",
"audio/x-m4a", "audio/flac", "audio/aac", "audio/mp3",
"audio/mpeg",
"audio/mp4",
"audio/ogg",
"audio/wav",
"audio/webm",
"audio/x-m4a",
"audio/flac",
"audio/aac",
"audio/mp3",
"video/ogg", // .ogg containers can be audio-only
]);
function isAudioMime(mime: string | undefined): boolean {
if (!mime) return false;
if (AUDIO_MIME_TYPES.has(mime)) return true;
return AUDIO_MIME_PREFIXES.some(p => mime.startsWith(p));
return AUDIO_MIME_PREFIXES.some((p) => mime.startsWith(p));
}
function isTextDocument(mimeType: string | undefined, filename: string | undefined): boolean {
@ -166,13 +210,17 @@ export function createTelegramAdapter(config: AdapterConfig): ChannelAdapter {
* Download a file from Telegram by file_id.
* Returns { path, size } or null on failure.
*/
async function downloadFile(fileId: string, suggestedName?: string, maxSize = MAX_FILE_SIZE): Promise<{ localPath: string; size: number } | null> {
async function downloadFile(
fileId: string,
suggestedName?: string,
maxSize = MAX_FILE_SIZE,
): Promise<{ localPath: string; size: number } | null> {
try {
// Get file info
const infoRes = await fetch(`${apiBase}/getFile?file_id=${fileId}`);
if (!infoRes.ok) return null;
const info = await infoRes.json() as {
const info = (await infoRes.json()) as {
ok: boolean;
result?: { file_id: string; file_size?: number; file_path?: string };
};
@ -237,7 +285,7 @@ export function createTelegramAdapter(config: AdapterConfig): ChannelAdapter {
continue;
}
const data = await res.json() as {
const data = (await res.json()) as {
ok: boolean;
result: Array<{ update_id: number; message?: TelegramMessage }>;
};
@ -574,7 +622,11 @@ export function createTelegramAdapter(config: AdapterConfig): ChannelAdapter {
function cleanupTempFiles(): void {
for (const f of tempFiles) {
try { fs.unlinkSync(f); } catch { /* ignore */ }
try {
fs.unlinkSync(f);
} catch {
/* ignore */
}
}
tempFiles.length = 0;
}
@ -663,7 +715,7 @@ interface TelegramMessage {
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}
function formatSize(bytes: number): string {

View file

@ -11,9 +11,9 @@
* const result = await provider.transcribe("/path/to/audio.ogg", "en");
*/
import { execFile } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { execFile } from "node:child_process";
import type { TranscriptionConfig } from "../types.ts";
// ── Public interface ────────────────────────────────────────────
@ -187,7 +187,7 @@ class OpenAIProvider implements TranscriptionProvider {
return { ok: false, error: `OpenAI API error (${response.status}): ${body.slice(0, 200)}` };
}
const data = await response.json() as { text?: string };
const data = (await response.json()) as { text?: string };
if (!data.text) {
return { ok: false, error: "OpenAI returned empty transcription" };
}
@ -238,7 +238,7 @@ class ElevenLabsProvider implements TranscriptionProvider {
return { ok: false, error: `ElevenLabs API error (${response.status}): ${body.slice(0, 200)}` };
}
const data = await response.json() as { text?: string };
const data = (await response.json()) as { text?: string };
if (!data.text) {
return { ok: false, error: "ElevenLabs returned empty transcription" };
}

View file

@ -11,7 +11,7 @@
* }
*/
import type { ChannelAdapter, ChannelMessage, AdapterConfig } from "../types.ts";
import type { AdapterConfig, ChannelAdapter, ChannelMessage } from "../types.ts";
export function createWebhookAdapter(config: AdapterConfig): ChannelAdapter {
const method = (config.method as string) ?? "POST";