From b132e9a0a22b2dc28acad3a179da56ee7b36d3af Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sat, 17 Jan 2026 15:13:26 +0000 Subject: [PATCH 01/90] Simplify bot template with Slack listener pattern - Adopt clean listener pattern from bolt-ts-starter template - Add Assistant API support for DM/thread handling - Create ResponseHandler interface for customizable responses - Hook up AI SDK when AI provider is selected - Remove complex directories (db, memory, preferences, agents) - Organize handlers by type: assistant, events, messages - Process responses asynchronously for 3-second ack requirement --- packages/create-bot/src/scaffold.ts | 5 - .../templates/slack-bot/config.yaml.tmpl | 87 +---- .../slack-bot/drizzle.config.ts.tmpl | 18 - .../templates/slack-bot/package.json.tmpl | 35 +- .../templates/slack-bot/src/ai/agents/base.ts | 150 -------- .../slack-bot/src/ai/agents/general.ts | 19 - .../slack-bot/src/ai/agents/index.ts | 6 - .../templates/slack-bot/src/ai/chat.ts | 52 --- .../templates/slack-bot/src/ai/tools/index.ts | 1 - .../slack-bot/src/ai/tools/memory.ts | 281 --------------- .../templates/slack-bot/src/app.ts.tmpl | 116 +++++++ .../slack-bot/src/config/http-server.ts.tmpl | 6 - .../slack-bot/src/config/loader.ts.tmpl | 8 - .../slack-bot/src/db/adapters/base.ts | 123 ------- .../slack-bot/src/db/adapters/postgres.ts | 281 --------------- .../slack-bot/src/db/adapters/sqlite.ts | 191 ---------- .../templates/slack-bot/src/db/index.ts.tmpl | 60 ---- .../templates/slack-bot/src/db/repository.ts | 11 - .../templates/slack-bot/src/db/schema.ts | 43 --- .../templates/slack-bot/src/db/types.ts | 23 -- .../templates/slack-bot/src/index.ts.tmpl | 60 ---- .../listeners/assistant/context-changed.ts | 12 + .../src/listeners/assistant/index.ts | 15 + .../src/listeners/assistant/message.ts | 76 ++++ .../src/listeners/assistant/thread-started.ts | 45 +++ .../src/listeners/events/app-mention.ts | 60 ++++ .../slack-bot/src/listeners/events/index.ts | 6 + .../slack-bot/src/listeners/index.ts | 10 + .../slack-bot/src/listeners/messages/index.ts | 72 ++++ .../templates/slack-bot/src/memory/store.ts | 87 ----- .../slack-bot/src/preferences/index.ts | 125 ------- .../slack-bot/src/response-handler.ts.tmpl | 76 ++++ .../templates/slack-bot/src/settings.ts.tmpl | 111 ++---- .../templates/slack-bot/src/slack/app.ts | 37 -- .../slack-bot/src/slack/handlers.ts.tmpl | 327 ------------------ .../slack-bot/src/slack/thread-tracker.ts | 84 ----- 36 files changed, 539 insertions(+), 2180 deletions(-) delete mode 100644 packages/create-bot/templates/slack-bot/drizzle.config.ts.tmpl delete mode 100644 packages/create-bot/templates/slack-bot/src/ai/agents/base.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/ai/agents/general.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/ai/agents/index.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/ai/chat.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/ai/tools/index.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/ai/tools/memory.ts create mode 100644 packages/create-bot/templates/slack-bot/src/app.ts.tmpl delete mode 100644 packages/create-bot/templates/slack-bot/src/db/adapters/base.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/db/adapters/postgres.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/db/adapters/sqlite.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/db/index.ts.tmpl delete mode 100644 packages/create-bot/templates/slack-bot/src/db/repository.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/db/schema.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/db/types.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/index.ts.tmpl create mode 100644 packages/create-bot/templates/slack-bot/src/listeners/assistant/context-changed.ts create mode 100644 packages/create-bot/templates/slack-bot/src/listeners/assistant/index.ts create mode 100644 packages/create-bot/templates/slack-bot/src/listeners/assistant/message.ts create mode 100644 packages/create-bot/templates/slack-bot/src/listeners/assistant/thread-started.ts create mode 100644 packages/create-bot/templates/slack-bot/src/listeners/events/app-mention.ts create mode 100644 packages/create-bot/templates/slack-bot/src/listeners/events/index.ts create mode 100644 packages/create-bot/templates/slack-bot/src/listeners/index.ts create mode 100644 packages/create-bot/templates/slack-bot/src/listeners/messages/index.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/memory/store.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/preferences/index.ts create mode 100644 packages/create-bot/templates/slack-bot/src/response-handler.ts.tmpl delete mode 100644 packages/create-bot/templates/slack-bot/src/slack/app.ts delete mode 100644 packages/create-bot/templates/slack-bot/src/slack/handlers.ts.tmpl delete mode 100644 packages/create-bot/templates/slack-bot/src/slack/thread-tracker.ts diff --git a/packages/create-bot/src/scaffold.ts b/packages/create-bot/src/scaffold.ts index e5e5d15..aee5786 100644 --- a/packages/create-bot/src/scaffold.ts +++ b/packages/create-bot/src/scaffold.ts @@ -110,11 +110,6 @@ async function copyDirectory( * Directories to skip based on context. */ function shouldSkipDirectory(dirName: string, ctx: TemplateContext): boolean { - // Skip AI-related directories when AI is disabled - if (!ctx.isAi && dirName === 'ai') { - return true - } - // Skip DB-related directories when no database selected // memory and preferences depend on db if ( diff --git a/packages/create-bot/templates/slack-bot/config.yaml.tmpl b/packages/create-bot/templates/slack-bot/config.yaml.tmpl index 85682a3..9281541 100644 --- a/packages/create-bot/templates/slack-bot/config.yaml.tmpl +++ b/packages/create-bot/templates/slack-bot/config.yaml.tmpl @@ -37,16 +37,6 @@ settings: label: Bot Personality description: System prompt describing how the bot should behave group: bot - - dm_history_limit: - value: 0 - schema: - type: number - label: DM History Limit - description: Number of previous DM messages to include (0 = none) - group: bot - min: 0 - max: 50 {{#if isAi}} # AI Provider @@ -77,62 +67,28 @@ settings: env: OPENAI_API_KEY schema: type: secret - label: API Key + label: OpenAI API Key group: ai - condition: - field: ai_provider - equals: openai - required_when: - field: ai_provider - equals: openai + required: true {{/if}} {{#if isAnthropic}} anthropic_api_key: env: ANTHROPIC_API_KEY schema: type: secret - label: API Key + label: Anthropic API Key group: ai - condition: - field: ai_provider - equals: anthropic - required_when: - field: ai_provider - equals: anthropic + required: true {{/if}} {{#if isGoogle}} google_api_key: env: GOOGLE_API_KEY schema: type: secret - label: API Key + label: Google API Key group: ai - condition: - field: ai_provider - equals: google - required_when: - field: ai_provider - equals: google + required: true {{/if}} - - # Model Configuration - model_fast: - schema: - type: model_select - label: Fast Model - description: Used for classification and quick tasks - group: models - tier: fast - provider_field: ai_provider - - model_default: - schema: - type: model_select - label: Default Model - description: Used for standard conversations - group: models - tier: default - provider_field: ai_provider {{/if}} # Group definitions with display order @@ -141,36 +97,7 @@ groups: - id: ai label: AI Provider order: 1 - - id: models - label: Model Configuration - order: 2 {{/if}} - id: bot label: Bot Configuration - order: {{#if isAi}}3{{else}}1{{/if}} -{{#if isAi}} - -# Model tiers per provider (single source of truth) -model_tiers: -{{#if isOpenai}} - openai: - fast: - - gpt-4o-mini - default: - - gpt-4o -{{/if}} -{{#if isAnthropic}} - anthropic: - fast: - - claude-3-5-haiku-latest - default: - - claude-sonnet-4-5 -{{/if}} -{{#if isGoogle}} - google: - fast: - - gemini-2.0-flash - default: - - gemini-2.5-pro -{{/if}} -{{/if}} + order: {{#if isAi}}2{{else}}1{{/if}} diff --git a/packages/create-bot/templates/slack-bot/drizzle.config.ts.tmpl b/packages/create-bot/templates/slack-bot/drizzle.config.ts.tmpl deleted file mode 100644 index cadcaea..0000000 --- a/packages/create-bot/templates/slack-bot/drizzle.config.ts.tmpl +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from 'drizzle-kit' - -export default defineConfig({ - schema: './src/db/schema.ts', - out: './drizzle', -{{~#if isSqlite}} - dialect: 'sqlite', - dbCredentials: { - url: `${process.env.DATA_DIR || './data'}/bot.sqlite`, - }, -{{~/if}} -{{~#if isPostgres}} - dialect: 'postgresql', - dbCredentials: { - url: process.env.DATABASE_URL!, - }, -{{~/if}} -}) diff --git a/packages/create-bot/templates/slack-bot/package.json.tmpl b/packages/create-bot/templates/slack-bot/package.json.tmpl index e56f88d..c0c64ea 100644 --- a/packages/create-bot/templates/slack-bot/package.json.tmpl +++ b/packages/create-bot/templates/slack-bot/package.json.tmpl @@ -5,16 +5,11 @@ "type": "module", "private": true, "scripts": { - "start": "bun run src/index.ts", - "dev": "bun --watch run src/index.ts", - "dev:local": "SLACK_API_URL=http://localhost:7557 bun --watch run src/index.ts", - "typecheck": "tsc --noEmit" - {{~#if isDb~}} - , - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio" - {{~/if}} + "start": "bun run src/app.ts", + "dev": "bun --watch run src/app.ts", + "dev:local": "SLACK_API_URL=http://localhost:7557 bun --watch run src/app.ts", + "typecheck": "tsc --noEmit", + "test": "bun test" }, "dependencies": { "@slack/bolt": "^4.6.0", @@ -24,36 +19,24 @@ "zod": "^4.3.5" {{~#if isAi~}} , - "ai": "^6.0.13" + "ai": "^4.3.16" {{~/if}} {{~#if isOpenai~}} , - "@ai-sdk/openai": "^3.0.6" + "@ai-sdk/openai": "^1.3.22" {{~/if}} {{~#if isAnthropic~}} , - "@ai-sdk/anthropic": "^3.0.7" + "@ai-sdk/anthropic": "^1.2.12" {{~/if}} {{~#if isGoogle~}} , - "@ai-sdk/google": "^3.0.5" - {{~/if}} - {{~#if isDb~}} - , - "drizzle-orm": "^0.45.1" - {{~/if}} - {{~#if isPostgres~}} - , - "postgres": "^3.4.8" + "@ai-sdk/google": "^1.2.20" {{~/if}} }, "devDependencies": { "@types/bun": "latest", "pino-pretty": "^13.1.3", "typescript": "^5" - {{~#if isDb~}} - , - "drizzle-kit": "^0.31.8" - {{~/if}} } } diff --git a/packages/create-bot/templates/slack-bot/src/ai/agents/base.ts b/packages/create-bot/templates/slack-bot/src/ai/agents/base.ts deleted file mode 100644 index dac834d..0000000 --- a/packages/create-bot/templates/slack-bot/src/ai/agents/base.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { generateText, type ToolSet } from 'ai' -import type { ChatContext } from '../chat' -import { getUserPreferences, type UserPreferences } from '../../preferences' -import { getModel, settings } from '../../settings' -import { chatLogger } from '../../utils/logger' -import { toSlackFormat, truncate } from '../../utils/string' -import { getErrorMessage } from '../../utils/error' - -export interface AgentConfig { - name: string - tools?: ToolSet - systemPromptBuilder: ( - context: ChatContext, - preferences: UserPreferences - ) => string -} - -/** - * Create a specialized agent with specific tools and system prompt. - */ -export function createAgent(config: AgentConfig) { - return async function agent( - message: string, - context: ChatContext, - history: Array<{ role: 'user' | 'assistant'; content: string }> = [] - ): Promise { - try { - const preferences = await getUserPreferences(context.user) - const systemPrompt = config.systemPromptBuilder(context, preferences) - - const messages = [...history, { role: 'user' as const, content: message }] - const useTools = config.tools && Object.keys(config.tools).length > 0 - - chatLogger.info( - { - agent: config.name, - user: context.user, - message: truncate(message, 100), - tools: useTools ? Object.keys(config.tools!).length : 0, - }, - `[${config.name}] Processing message` - ) - - const result = await generateText({ - model: getModel(), - system: systemPrompt, - messages, - ...(useTools && { tools: config.tools, maxSteps: 5 }), - }) - - const usage = result.usage - chatLogger.info( - { - agent: config.name, - steps: result.steps.length, - inputTokens: usage?.inputTokens, - outputTokens: usage?.outputTokens, - }, - `[${config.name}] Response received` - ) - - return toSlackFormat( - result.text || 'I processed your request but have no text response.' - ) - } catch (error) { - const errorMsg = getErrorMessage(error) - chatLogger.error( - { agent: config.name, error: errorMsg }, - `[${config.name}] Error` - ) - return `Sorry, I encountered an error processing your request. Please try again.` - } - } -} - -// Response style instructions -export const RESPONSE_STYLE_INSTRUCTIONS: Record< - UserPreferences['responseStyle'], - string -> = { - balanced: '- Be helpful and balanced in your responses', - concise: '- Be brief and concise - give short, direct answers', - detailed: '- Be detailed and thorough - provide comprehensive explanations', -} - -/** - * Build base guidelines that apply to all agents. - */ -export function buildBaseGuidelines( - context: ChatContext, - preferences: UserPreferences -): string { - const botName = context.botName || settings.BOT_NAME - const personality = settings.BOT_PERSONALITY - const threadStatus = context.thread_ts - ? `Thread: ${context.thread_ts}` - : 'This is a new conversation.' - - const now = new Date() - let currentDateTime: string - try { - currentDateTime = now.toLocaleString('en-GB', { - timeZone: preferences.timezone, - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - timeZoneName: 'short', - }) - } catch { - currentDateTime = now.toLocaleString('en-GB', { - timeZone: 'UTC', - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - timeZoneName: 'short', - }) - } - - const platformContext = `You are chatting in Slack. The current user is <@${context.user}>. -Channel: ${context.channel}` - - const languageInstruction = - preferences.language !== 'en' - ? `- Respond in ${preferences.language} (the user's preferred language)` - : '' - - return `You are ${botName}, ${personality}. - -Current date/time: ${currentDateTime} - -${platformContext} -${threadStatus} - -## User Preferences -- Response style: ${preferences.responseStyle} -- Language: ${preferences.language} -- Timezone: ${preferences.timezone} - -Guidelines: -${RESPONSE_STYLE_INSTRUCTIONS[preferences.responseStyle]} -${languageInstruction} -- Use Slack mrkdwn formatting (bold: *text*, italic: _text_, code: \`code\`) -- Be friendly but professional` -} diff --git a/packages/create-bot/templates/slack-bot/src/ai/agents/general.ts b/packages/create-bot/templates/slack-bot/src/ai/agents/general.ts deleted file mode 100644 index 9a4b2bb..0000000 --- a/packages/create-bot/templates/slack-bot/src/ai/agents/general.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createAgent, buildBaseGuidelines } from './base' -import { memoryTools } from '../tools' - -/** - * General agent - handles general conversation and questions. - * Has memory tools for preference handling. - */ -export const generalAgent = createAgent({ - name: 'General', - tools: memoryTools, - systemPromptBuilder: (context, preferences) => { - return `${buildBaseGuidelines(context, preferences)} -- Handle general questions and conversation naturally -- When the user expresses a preference, use setUserPreference to save it: - - "keep it brief" -> setUserPreference(responseStyle: "concise") - - "respond in Spanish" -> setUserPreference(language: "es") - - "I'm in New York" -> setUserPreference(timezone: "America/New_York")` - }, -}) diff --git a/packages/create-bot/templates/slack-bot/src/ai/agents/index.ts b/packages/create-bot/templates/slack-bot/src/ai/agents/index.ts deleted file mode 100644 index 39d8a48..0000000 --- a/packages/create-bot/templates/slack-bot/src/ai/agents/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { generalAgent } from './general' -export { - createAgent, - buildBaseGuidelines, - RESPONSE_STYLE_INSTRUCTIONS, -} from './base' diff --git a/packages/create-bot/templates/slack-bot/src/ai/chat.ts b/packages/create-bot/templates/slack-bot/src/ai/chat.ts deleted file mode 100644 index dc2a965..0000000 --- a/packages/create-bot/templates/slack-bot/src/ai/chat.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { generalAgent } from './agents' -import { truncate } from '../utils/string' -import { chatLogger } from '../utils/logger' - -export interface ChatContext { - channel: string - thread_ts?: string - user: string - botName: string -} - -/** - * Process a message using the general agent. - */ -export async function chat( - message: string, - context: ChatContext -): Promise { - chatLogger.info( - { user: context.user, message: truncate(message, 100) }, - 'Processing message' - ) - return generalAgent(message, context) -} - -/** - * Chat with conversation history for multi-turn conversations. - */ -export async function chatWithHistory( - messages: Array<{ role: 'user' | 'assistant'; content: string }>, - context: ChatContext -): Promise { - if (messages.length === 0) { - return 'No message provided.' - } - - const lastUserMessage = [...messages] - .reverse() - .find((m) => m.role === 'user')?.content - - if (!lastUserMessage) { - return 'No user message found.' - } - - chatLogger.info( - { user: context.user, message: truncate(lastUserMessage, 100) }, - 'Processing message with history' - ) - - const history = messages.slice(0, -1) - return generalAgent(lastUserMessage, context, history) -} diff --git a/packages/create-bot/templates/slack-bot/src/ai/tools/index.ts b/packages/create-bot/templates/slack-bot/src/ai/tools/index.ts deleted file mode 100644 index 3f1a9f2..0000000 --- a/packages/create-bot/templates/slack-bot/src/ai/tools/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { memoryTools } from './memory' diff --git a/packages/create-bot/templates/slack-bot/src/ai/tools/memory.ts b/packages/create-bot/templates/slack-bot/src/ai/tools/memory.ts deleted file mode 100644 index c60351a..0000000 --- a/packages/create-bot/templates/slack-bot/src/ai/tools/memory.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { tool } from 'ai' -import { z } from 'zod' -import { memoryStore } from '../../memory/store' -import { - saveUserPreferences, - normalizeResponseStyle, - normalizeTimezone, -} from '../../preferences' -import { - type ToolResult, - success, - failure, - withToolLogging, -} from '../../utils/tools' - -const categorySchema = z - .enum(['user', 'project', 'fact', 'preference']) - .describe( - 'Category: user, project, fact (schedules/times/events), or preference' - ) - -interface StoreMemoryInput { - category: 'user' | 'project' | 'fact' | 'preference' - key: string - content: string - tags?: string[] - channelId?: string - userId?: string -} - -interface RetrieveMemoryInput { - category: 'user' | 'project' | 'fact' | 'preference' - key: string - userId?: string - channelId?: string -} - -interface SearchMemoriesInput { - query: string - category?: 'user' | 'project' | 'fact' | 'preference' - tags?: string[] - channelId?: string - userId?: string -} - -interface SetPreferenceInput { - userId: string - preference: 'responseStyle' | 'language' | 'timezone' - value: string -} - -interface DeleteMemoryInput { - category: 'user' | 'project' | 'fact' | 'preference' - key: string - userId?: string -} - -async function storeMemoryExecute( - input: StoreMemoryInput -): Promise> { - const { category, key, content, tags, channelId, userId } = input - - if (category === 'user' || category === 'preference') { - if (!userId) { - return failure('userId required to store user/preference memories') - } - const isOwnMemory = - category === 'user' - ? key === userId - : key.startsWith(`${userId}:`) || key === userId - if (!isOwnMemory) { - return failure('You can only store your own user/preference memories') - } - } - - const source = channelId && userId ? { channelId, userId } : undefined - await memoryStore.store({ category, key, content, tags, source }) - return success({ message: `Memory stored: ${category}/${key}` }) -} - -async function retrieveMemoryExecute( - input: RetrieveMemoryInput -): Promise> { - const { category, key, userId } = input - - if (category === 'user' || category === 'preference') { - if (!userId) { - return failure('userId required to retrieve user/preference memories') - } - const isOwnMemory = - category === 'user' - ? key === userId - : key.startsWith(`${userId}:`) || key === userId - if (!isOwnMemory) { - return failure('You can only retrieve your own user/preference memories') - } - } - - const memory = await memoryStore.retrieve(category, key) - if (!memory) { - return failure('Memory not found') - } - - return success({ memory }) -} - -async function searchMemoriesExecute( - input: SearchMemoriesInput -): Promise> { - const { query, category, tags, channelId, userId } = input - - const results = await memoryStore.search({ - query, - category, - tags, - channelId, - }) - - const filteredResults = results.filter((memory) => { - if (memory.category === 'user' || memory.category === 'preference') { - if (!userId) return false - const isOwnMemory = - memory.category === 'user' - ? memory.key === userId - : memory.key.startsWith(`${userId}:`) || memory.key === userId - return isOwnMemory - } - return true - }) - - return success({ results: filteredResults }) -} - -async function setUserPreferenceExecute( - input: SetPreferenceInput -): Promise> { - const { userId, preference, value } = input - - let normalizedValue: string = value - - if (preference === 'responseStyle') { - const normalized = normalizeResponseStyle(value) - if (!normalized) { - return failure( - `Invalid response style "${value}". Use: concise, detailed, or balanced` - ) - } - normalizedValue = normalized - } else if (preference === 'timezone') { - const normalized = normalizeTimezone(value) - if (!normalized) { - return failure( - `Invalid timezone "${value}". Use a valid IANA timezone like "America/New_York" or "UTC".` - ) - } - normalizedValue = normalized - } - - await saveUserPreferences(userId, { [preference]: normalizedValue }) - return success({ - message: `Preference updated: ${preference} = ${normalizedValue}`, - }) -} - -async function deleteMemoryExecute( - input: DeleteMemoryInput -): Promise> { - const { category, key, userId } = input - - if (category === 'user' || category === 'preference') { - if (!userId) { - return failure('userId required to delete user/preference memories') - } - const isOwnMemory = - category === 'user' - ? key === userId - : key.startsWith(`${userId}:`) || key === userId - if (!isOwnMemory) { - return failure('You can only delete your own user/preference memories') - } - } - - const deleted = await memoryStore.delete(category, key) - if (deleted) { - return success({ message: `Memory deleted: ${category}/${key}` }) - } - return failure('Memory not found') -} - -export const memoryTools = { - storeMemory: tool({ - description: - 'Store important information for later recall. Always include channelId and userId from the current context.', - inputSchema: z.object({ - category: categorySchema, - key: z.string().describe('Unique identifier for this memory'), - content: z.string().describe('The information to remember'), - tags: z - .array(z.string()) - .optional() - .describe('Optional tags for easier retrieval'), - channelId: z - .string() - .optional() - .describe('Channel where this was learned'), - userId: z.string().optional().describe('User who provided the info'), - }), - execute: withToolLogging( - 'storeMemory', - (input: StoreMemoryInput) => `Storing ${input.category}/${input.key}`, - storeMemoryExecute - ), - }), - - retrieveMemory: tool({ - description: 'Retrieve a specific memory by exact category and key.', - inputSchema: z.object({ - category: categorySchema, - key: z.string().describe('The key used when storing'), - userId: z.string().optional().describe('Current user ID'), - channelId: z.string().optional().describe('Current channel ID'), - }), - execute: withToolLogging( - 'retrieveMemory', - (input: RetrieveMemoryInput) => - `Retrieving ${input.category}/${input.key}`, - retrieveMemoryExecute - ), - }), - - searchMemories: tool({ - description: 'Search memories by query text or tags.', - inputSchema: z.object({ - query: z.string().describe('Text to search for in memories'), - category: categorySchema.optional().describe('Optional category filter'), - tags: z - .array(z.string()) - .optional() - .describe('Optional tags to filter by'), - channelId: z.string().optional().describe('Channel to search in'), - userId: z.string().optional().describe('Current user ID'), - }), - execute: withToolLogging( - 'searchMemories', - (input: SearchMemoriesInput) => `Query: "${input.query}"`, - searchMemoriesExecute - ), - }), - - setUserPreference: tool({ - description: - 'Update a user preference. Use when the user expresses a preference like "I prefer concise answers".', - inputSchema: z.object({ - userId: z.string().describe('The user ID to set preference for'), - preference: z - .enum(['responseStyle', 'language', 'timezone']) - .describe('Which preference to set'), - value: z.string().describe('The value to set'), - }), - execute: withToolLogging( - 'setUserPreference', - (input: SetPreferenceInput) => - `Setting ${input.preference}=${input.value} for user ${input.userId}`, - setUserPreferenceExecute - ), - }), - - deleteMemory: tool({ - description: 'Delete a stored memory by category and key.', - inputSchema: z.object({ - category: categorySchema, - key: z.string().describe('The exact key of the memory to delete'), - userId: z.string().optional().describe('Current user ID'), - }), - execute: withToolLogging( - 'deleteMemory', - (input: DeleteMemoryInput) => `Deleting ${input.category}/${input.key}`, - deleteMemoryExecute - ), - }), -} diff --git a/packages/create-bot/templates/slack-bot/src/app.ts.tmpl b/packages/create-bot/templates/slack-bot/src/app.ts.tmpl new file mode 100644 index 0000000..9d49c78 --- /dev/null +++ b/packages/create-bot/templates/slack-bot/src/app.ts.tmpl @@ -0,0 +1,116 @@ +import { App, LogLevel } from '@slack/bolt' +import { registerListeners } from './listeners/index' +import { settings, isSimulatorMode } from './settings' +import { startConfigServer } from './config/http-server' +import { slackConfig } from './config/loader' +import { appLogger, slackLogger } from './utils/logger' + +// Simulator tokens for local mode +const SIMULATOR_BOT_TOKEN = 'xoxb-simulator-token' +const SIMULATOR_APP_TOKEN = 'xapp-simulator-token' + +function createApp() { + const logLevel = + settings.LOG_LEVEL === 'debug' ? LogLevel.DEBUG : LogLevel.INFO + + if (isSimulatorMode) { + slackLogger.info( + { apiUrl: process.env.SLACK_API_URL }, + 'Connecting to simulator' + ) + return new App({ + token: SIMULATOR_BOT_TOKEN, + appToken: SIMULATOR_APP_TOKEN, + socketMode: true, + logLevel, + clientOptions: { + slackApiUrl: process.env.SLACK_API_URL, + }, + }) + } + + return new App({ + token: settings.SLACK_BOT_TOKEN, + appToken: settings.SLACK_APP_TOKEN, + socketMode: true, + logLevel, + }) +} + +async function registerWithSimulator(maxRetries = 30, retryDelayMs = 2000) { + const apiUrl = process.env.SLACK_API_URL + if (!apiUrl) return + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(`${apiUrl}/api/config/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(slackConfig), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + slackLogger.info('Registered with simulator') + return + } catch (error) { + if (attempt < maxRetries) { + slackLogger.debug( + { attempt, maxRetries }, + 'Simulator not ready, retrying...' + ) + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)) + } else { + slackLogger.warn({ error }, 'Failed to register with simulator') + } + } + } +} + +async function main() { + appLogger.info({ simulatorMode: isSimulatorMode }, 'Starting bot...') + + // Start config server for simulator + if (isSimulatorMode) { + const configPort = settings.PORT + 1 + startConfigServer(configPort) + } + + const app = createApp() + registerListeners(app) + await app.start() + + // Register with simulator after WebSocket is connected + if (isSimulatorMode) { + await registerWithSimulator() + + // Re-register on reconnection + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const receiver = (app as any).receiver as { client?: { on?: Function } } + receiver?.client?.on?.('connected', () => { + slackLogger.info('WebSocket reconnected, re-registering...') + registerWithSimulator().catch((err) => { + slackLogger.error({ err }, 'Failed to re-register with simulator') + }) + }) + } + + // Graceful shutdown + const shutdown = async () => { + appLogger.info('Shutting down...') + await app.stop() + process.exit(0) + } + + process.on('SIGTERM', shutdown) + process.on('SIGINT', shutdown) + + appLogger.info(`${settings.BOT_NAME} is running!`) +} + +main().catch((error) => { + appLogger.fatal({ error }, 'Fatal error') + process.exit(1) +}) diff --git a/packages/create-bot/templates/slack-bot/src/config/http-server.ts.tmpl b/packages/create-bot/templates/slack-bot/src/config/http-server.ts.tmpl index d1dae2a..66ec940 100644 --- a/packages/create-bot/templates/slack-bot/src/config/http-server.ts.tmpl +++ b/packages/create-bot/templates/slack-bot/src/config/http-server.ts.tmpl @@ -12,9 +12,6 @@ export interface ConfigResponse { schema: { settings: Record groups: GroupDefinition[] -{{~#if isAi}} - model_tiers: Record> -{{~/if}} } values: Record } @@ -46,9 +43,6 @@ function getConfigResponse(): ConfigResponse { schema: { settings: settingsSchema, groups: config.groups, -{{~#if isAi}} - model_tiers: config.model_tiers, -{{~/if}} }, values, } diff --git a/packages/create-bot/templates/slack-bot/src/config/loader.ts.tmpl b/packages/create-bot/templates/slack-bot/src/config/loader.ts.tmpl index 40d32c9..46069e9 100644 --- a/packages/create-bot/templates/slack-bot/src/config/loader.ts.tmpl +++ b/packages/create-bot/templates/slack-bot/src/config/loader.ts.tmpl @@ -90,9 +90,6 @@ export interface ConfigFile { slack: SlackConfig settings: Record groups: GroupDefinition[] -{{#if isAi}} - model_tiers: Record> -{{/if}} } // Export typed config @@ -107,8 +104,3 @@ export const slackConfig = { app: { name: botName, id: config.simulator.id }, ...config.slack, } -{{~#if isAi}} - -// Export model tiers from config -export const MODEL_TIERS = config.model_tiers -{{~/if}} diff --git a/packages/create-bot/templates/slack-bot/src/db/adapters/base.ts b/packages/create-bot/templates/slack-bot/src/db/adapters/base.ts deleted file mode 100644 index db463ee..0000000 --- a/packages/create-bot/templates/slack-bot/src/db/adapters/base.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { Memory, SearchParams } from '../types' -import type { MemoryRepository } from '../repository' - -export interface MemoryRow { - id: number - category: string - key: string - content: string - tags: string | string[] | null - source: - | string - | { channelId: string; userId: string; threadTs?: string } - | null - createdAt: string | Date - updatedAt: string | Date -} - -export abstract class BaseAdapter implements MemoryRepository { - abstract initialize(): Promise - abstract store( - memory: Omit - ): Promise - abstract close(): Promise - - protected abstract queryByCategoryAndKey( - category: string, - key: string - ): Promise - - protected abstract queryTextSearch( - pattern: string, - category?: string, - channelId?: string - ): Promise - - protected abstract queryAll( - category?: string, - channelId?: string - ): Promise - - protected abstract executeDelete( - category: string, - key: string - ): Promise - - async retrieve(category: string, key: string): Promise { - const rows = await this.queryByCategoryAndKey(category, key) - return rows[0] ? this.rowToMemory(rows[0]) : null - } - - async search(params: SearchParams): Promise { - const { query, category, tags, channelId } = params - - if (query.trim()) { - const rows = await this.queryTextSearch(query, category, channelId) - const memories = rows.map((row) => this.rowToMemory(row)) - return this.filterByTags(memories, tags) - } - - const rows = await this.queryAll(category, channelId) - const memories = rows.map((row) => this.rowToMemory(row)) - return this.filterByTags(memories, tags) - } - - async delete(category: string, key: string): Promise { - return this.executeDelete(category, key) - } - - async list(category?: string): Promise { - const rows = await this.queryAll(category) - return rows.map((row) => this.rowToMemory(row)) - } - - protected filterByTags(memories: Memory[], tags?: string[]): Memory[] { - if (!tags || tags.length === 0) { - return memories - } - return memories.filter((memory) => { - const memoryTags = memory.tags || [] - return tags.some((tag) => memoryTags.includes(tag)) - }) - } - - protected rowToMemory(row: MemoryRow): Memory { - return { - id: row.id, - category: row.category as Memory['category'], - key: row.key, - content: row.content, - tags: this.parseTags(row.tags), - source: this.parseSource(row.source), - createdAt: this.parseTimestamp(row.createdAt), - updatedAt: this.parseTimestamp(row.updatedAt), - } - } - - private parseTags(tags: MemoryRow['tags']): string[] | undefined { - if (!tags) return undefined - if (Array.isArray(tags)) return tags - try { - return JSON.parse(tags) - } catch { - return undefined - } - } - - private parseSource(source: MemoryRow['source']): Memory['source'] { - if (!source) return undefined - if (typeof source === 'object') return source - try { - return JSON.parse(source) - } catch { - return undefined - } - } - - private parseTimestamp(timestamp: string | Date): string { - if (timestamp instanceof Date) { - return timestamp.toISOString() - } - return timestamp - } -} diff --git a/packages/create-bot/templates/slack-bot/src/db/adapters/postgres.ts b/packages/create-bot/templates/slack-bot/src/db/adapters/postgres.ts deleted file mode 100644 index 63f4964..0000000 --- a/packages/create-bot/templates/slack-bot/src/db/adapters/postgres.ts +++ /dev/null @@ -1,281 +0,0 @@ -import postgres from 'postgres' -import { drizzle } from 'drizzle-orm/postgres-js' -import { eq, and, ilike, or, sql } from 'drizzle-orm' -import type { Memory, SearchParams } from '../types' -import { memoriesPostgres } from '../schema' -import { settings } from '../../settings' -import { BaseAdapter, type MemoryRow } from './base' - -export class PostgresAdapter extends BaseAdapter { - private db: ReturnType - private client: ReturnType - - constructor(connectionString?: string) { - super() - const url = - connectionString || (settings as { DATABASE_URL?: string }).DATABASE_URL - if (!url) { - throw new Error( - 'DATABASE_URL environment variable is required for Postgres' - ) - } - this.client = postgres(url) - this.db = drizzle(this.client) - } - - async initialize(): Promise { - await this.client` - CREATE TABLE IF NOT EXISTS memories ( - id SERIAL PRIMARY KEY, - category TEXT NOT NULL, - key TEXT NOT NULL, - content TEXT NOT NULL, - tags JSONB, - source JSONB, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(category, key) - ) - ` - - await this.client` - CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category) - ` - await this.client` - CREATE INDEX IF NOT EXISTS idx_memories_channel_id ON memories((source->>'channelId')) - ` - await this.client` - CREATE INDEX IF NOT EXISTS idx_memories_tags ON memories USING GIN(tags) - ` - } - - async store( - memory: Omit - ): Promise { - await this.db - .insert(memoriesPostgres) - .values({ - category: memory.category, - key: memory.key, - content: memory.content, - tags: memory.tags ?? null, - source: memory.source ?? null, - }) - .onConflictDoUpdate({ - target: [memoriesPostgres.category, memoriesPostgres.key], - set: { - content: memory.content, - tags: memory.tags ?? null, - source: memory.source ?? null, - updatedAt: sql`NOW()`, - }, - }) - } - - async close(): Promise { - await this.client.end() - } - - protected async queryByCategoryAndKey( - category: string, - key: string - ): Promise { - const results = await this.db - .select() - .from(memoriesPostgres) - .where( - and( - eq(memoriesPostgres.category, category), - eq(memoriesPostgres.key, key) - ) - ) - .limit(1) - - return results as MemoryRow[] - } - - private escapeLikePattern(query: string): string { - return query.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') - } - - protected async queryTextSearch( - query: string, - category?: string, - channelId?: string - ): Promise { - const conditions = [] - - const queryPattern = `%${this.escapeLikePattern(query)}%` - conditions.push( - or( - ilike(memoriesPostgres.key, queryPattern), - ilike(memoriesPostgres.content, queryPattern) - ) - ) - - if (category) { - conditions.push(eq(memoriesPostgres.category, category)) - } - - if (channelId) { - conditions.push( - or( - sql`${memoriesPostgres.source}->>'channelId' = ${channelId}`, - sql`${memoriesPostgres.source} IS NULL`, - sql`${memoriesPostgres.tags} ? 'global'` - ) - ) - } - - const results = await this.db - .select() - .from(memoriesPostgres) - .where(and(...conditions)) - - return results as MemoryRow[] - } - - protected async queryAll( - category?: string, - channelId?: string - ): Promise { - const conditions = [] - - if (category) { - conditions.push(eq(memoriesPostgres.category, category)) - } - - if (channelId) { - conditions.push( - or( - sql`${memoriesPostgres.source}->>'channelId' = ${channelId}`, - sql`${memoriesPostgres.source} IS NULL`, - sql`${memoriesPostgres.tags} ? 'global'` - ) - ) - } - - const results = - conditions.length > 0 - ? await this.db - .select() - .from(memoriesPostgres) - .where(and(...conditions)) - : await this.db.select().from(memoriesPostgres) - - return results as MemoryRow[] - } - - protected async executeDelete( - category: string, - key: string - ): Promise { - const result = await this.db - .delete(memoriesPostgres) - .where( - and( - eq(memoriesPostgres.category, category), - eq(memoriesPostgres.key, key) - ) - ) - - return (result as unknown as { rowCount: number }).rowCount > 0 - } - - override async search(params: SearchParams): Promise { - const { query, category, tags, channelId } = params - - if (query.trim()) { - const rows = await this.queryTextSearchWithTags( - query, - category, - channelId, - tags - ) - if (rows.length > 0) { - return rows.map((row) => this.rowToMemory(row)) - } - } - - const rows = await this.queryAllWithTags(category, channelId, tags) - return rows.map((row) => this.rowToMemory(row)) - } - - private async queryTextSearchWithTags( - query: string, - category?: string, - channelId?: string, - tags?: string[] - ): Promise { - const conditions = [] - - const queryPattern = `%${this.escapeLikePattern(query)}%` - conditions.push( - or( - ilike(memoriesPostgres.key, queryPattern), - ilike(memoriesPostgres.content, queryPattern) - ) - ) - - if (category) { - conditions.push(eq(memoriesPostgres.category, category)) - } - - if (channelId) { - conditions.push( - or( - sql`${memoriesPostgres.source}->>'channelId' = ${channelId}`, - sql`${memoriesPostgres.source} IS NULL`, - sql`${memoriesPostgres.tags} ? 'global'` - ) - ) - } - - if (tags && tags.length > 0) { - conditions.push(sql`${memoriesPostgres.tags} ?| ${tags}`) - } - - const results = await this.db - .select() - .from(memoriesPostgres) - .where(and(...conditions)) - - return results as MemoryRow[] - } - - private async queryAllWithTags( - category?: string, - channelId?: string, - tags?: string[] - ): Promise { - const conditions = [] - - if (category) { - conditions.push(eq(memoriesPostgres.category, category)) - } - - if (channelId) { - conditions.push( - or( - sql`${memoriesPostgres.source}->>'channelId' = ${channelId}`, - sql`${memoriesPostgres.source} IS NULL`, - sql`${memoriesPostgres.tags} ? 'global'` - ) - ) - } - - if (tags && tags.length > 0) { - conditions.push(sql`${memoriesPostgres.tags} ?| ${tags}`) - } - - const results = - conditions.length > 0 - ? await this.db - .select() - .from(memoriesPostgres) - .where(and(...conditions)) - : await this.db.select().from(memoriesPostgres) - - return results as MemoryRow[] - } -} diff --git a/packages/create-bot/templates/slack-bot/src/db/adapters/sqlite.ts b/packages/create-bot/templates/slack-bot/src/db/adapters/sqlite.ts deleted file mode 100644 index 35f1628..0000000 --- a/packages/create-bot/templates/slack-bot/src/db/adapters/sqlite.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { Database } from 'bun:sqlite' -import { drizzle } from 'drizzle-orm/bun-sqlite' -import { eq, and, like, or, sql } from 'drizzle-orm' -import { join, resolve, dirname } from 'path' -import { mkdir } from 'fs/promises' -import type { Memory } from '../types' -import { memoriesSqlite } from '../schema' -import { settings } from '../../settings' -import { BaseAdapter, type MemoryRow } from './base' - -export class SQLiteAdapter extends BaseAdapter { - private db!: ReturnType - private sqlite!: Database - private isMemoryDb: boolean - private dbPath: string - - constructor(dbPath?: string) { - super() - this.isMemoryDb = dbPath === ':memory:' - - if (this.isMemoryDb) { - this.dbPath = ':memory:' - } else { - const dataDir = process.env.DATA_DIR || settings.DATA_DIR - this.dbPath = dbPath || resolve(join(dataDir, 'bot.sqlite')) - } - } - - async initialize(): Promise { - if (!this.isMemoryDb) { - const dbDir = dirname(this.dbPath) - await mkdir(dbDir, { recursive: true }) - } - - this.sqlite = new Database(this.dbPath, { create: true, strict: true }) - this.db = drizzle(this.sqlite) - - this.sqlite.run('PRAGMA journal_mode = WAL') - - this.sqlite.run(` - CREATE TABLE IF NOT EXISTS memories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - category TEXT NOT NULL, - key TEXT NOT NULL, - content TEXT NOT NULL, - tags TEXT, - source TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE(category, key) - ) - `) - - this.sqlite.run( - 'CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category)' - ) - } - - async store( - memory: Omit - ): Promise { - const now = new Date().toISOString() - const tags = memory.tags ? JSON.stringify(memory.tags) : null - const source = memory.source ? JSON.stringify(memory.source) : null - - await this.db - .insert(memoriesSqlite) - .values({ - category: memory.category, - key: memory.key, - content: memory.content, - tags, - source, - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [memoriesSqlite.category, memoriesSqlite.key], - set: { - content: memory.content, - tags, - source, - updatedAt: now, - }, - }) - } - - async close(): Promise { - this.sqlite.close() - } - - protected async queryByCategoryAndKey( - category: string, - key: string - ): Promise { - const results = await this.db - .select() - .from(memoriesSqlite) - .where( - and(eq(memoriesSqlite.category, category), eq(memoriesSqlite.key, key)) - ) - .limit(1) - - return results as MemoryRow[] - } - - private escapeLikePattern(query: string): string { - return query.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') - } - - protected async queryTextSearch( - query: string, - category?: string, - channelId?: string - ): Promise { - const conditions = [] - - const queryLower = `%${this.escapeLikePattern(query.toLowerCase())}%` - conditions.push( - or( - sql`lower(${memoriesSqlite.key}) LIKE ${queryLower} ESCAPE '\\'`, - sql`lower(${memoriesSqlite.content}) LIKE ${queryLower} ESCAPE '\\'` - ) - ) - - if (category) { - conditions.push(eq(memoriesSqlite.category, category)) - } - - if (channelId) { - conditions.push( - or( - sql`json_extract(${memoriesSqlite.source}, '$.channelId') = ${channelId}`, - sql`${memoriesSqlite.source} IS NULL`, - sql`EXISTS(SELECT 1 FROM json_each(${memoriesSqlite.tags}) WHERE value = 'global')` - ) - ) - } - - const results = await this.db - .select() - .from(memoriesSqlite) - .where(and(...conditions)) - - return results as MemoryRow[] - } - - protected async queryAll( - category?: string, - channelId?: string - ): Promise { - const conditions = [] - - if (category) { - conditions.push(eq(memoriesSqlite.category, category)) - } - - if (channelId) { - conditions.push( - or( - sql`json_extract(${memoriesSqlite.source}, '$.channelId') = ${channelId}`, - sql`${memoriesSqlite.source} IS NULL`, - sql`EXISTS(SELECT 1 FROM json_each(${memoriesSqlite.tags}) WHERE value = 'global')` - ) - ) - } - - const results = - conditions.length > 0 - ? await this.db - .select() - .from(memoriesSqlite) - .where(and(...conditions)) - : await this.db.select().from(memoriesSqlite) - - return results as MemoryRow[] - } - - protected async executeDelete( - category: string, - key: string - ): Promise { - const result = await this.db - .delete(memoriesSqlite) - .where( - and(eq(memoriesSqlite.category, category), eq(memoriesSqlite.key, key)) - ) - - return (result as unknown as { changes: number }).changes > 0 - } -} diff --git a/packages/create-bot/templates/slack-bot/src/db/index.ts.tmpl b/packages/create-bot/templates/slack-bot/src/db/index.ts.tmpl deleted file mode 100644 index e7c87be..0000000 --- a/packages/create-bot/templates/slack-bot/src/db/index.ts.tmpl +++ /dev/null @@ -1,60 +0,0 @@ -import type { MemoryRepository } from './repository' -import { settings } from '../settings' -import { dbLogger } from '../utils/logger' - -export type { Memory, MemorySource, SearchParams } from './types' -export type { MemoryRepository } from './repository' - -class RepositoryManager { - private repository: MemoryRepository | null = null - private initializingPromise: Promise | null = null - - async createRepository(): Promise { -{{~#if isSqlite}} - const { SQLiteAdapter } = await import('./adapters/sqlite') - dbLogger.info('Using SQLite adapter') - return new SQLiteAdapter() -{{~/if}} -{{~#if isPostgres}} - const { PostgresAdapter } = await import('./adapters/postgres') - dbLogger.info('Using Postgres adapter') - return new PostgresAdapter() -{{~/if}} - } - - async get(): Promise { - if (this.repository) { - return this.repository - } - - if (this.initializingPromise) { - return this.initializingPromise - } - - this.initializingPromise = (async () => { - const repo = await this.createRepository() - await repo.initialize() - this.repository = repo - this.initializingPromise = null - return repo - })() - - return this.initializingPromise - } - - async close(): Promise { - if (this.initializingPromise) { - await this.initializingPromise - } - - if (this.repository) { - await this.repository.close() - this.repository = null - } - } -} - -const manager = new RepositoryManager() - -export const getRepository = () => manager.get() -export const closeRepository = () => manager.close() diff --git a/packages/create-bot/templates/slack-bot/src/db/repository.ts b/packages/create-bot/templates/slack-bot/src/db/repository.ts deleted file mode 100644 index 052be5f..0000000 --- a/packages/create-bot/templates/slack-bot/src/db/repository.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Memory, SearchParams } from './types' - -export interface MemoryRepository { - initialize(): Promise - store(memory: Omit): Promise - retrieve(category: string, key: string): Promise - search(params: SearchParams): Promise - delete(category: string, key: string): Promise - list(category?: string): Promise - close(): Promise -} diff --git a/packages/create-bot/templates/slack-bot/src/db/schema.ts b/packages/create-bot/templates/slack-bot/src/db/schema.ts deleted file mode 100644 index 4dadc26..0000000 --- a/packages/create-bot/templates/slack-bot/src/db/schema.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' -import { - pgTable, - serial, - text as pgText, - jsonb, - timestamp, -} from 'drizzle-orm/pg-core' - -// SQLite schema -export const memoriesSqlite = sqliteTable('memories', { - id: integer('id').primaryKey({ autoIncrement: true }), - category: text('category').notNull(), - key: text('key').notNull(), - content: text('content').notNull(), - tags: text('tags'), - source: text('source'), - createdAt: text('created_at').notNull(), - updatedAt: text('updated_at').notNull(), -}) - -// PostgreSQL schema -export const memoriesPostgres = pgTable('memories', { - id: serial('id').primaryKey(), - category: pgText('category').notNull(), - key: pgText('key').notNull(), - content: pgText('content').notNull(), - tags: jsonb('tags').$type(), - source: jsonb('source').$type<{ - channelId: string - userId: string - threadTs?: string - }>(), - createdAt: timestamp('created_at', { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }) - .notNull() - .defaultNow(), -}) - -export type MemorySqlite = typeof memoriesSqlite.$inferSelect -export type MemoryPostgres = typeof memoriesPostgres.$inferSelect diff --git a/packages/create-bot/templates/slack-bot/src/db/types.ts b/packages/create-bot/templates/slack-bot/src/db/types.ts deleted file mode 100644 index 78e6171..0000000 --- a/packages/create-bot/templates/slack-bot/src/db/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface MemorySource { - channelId: string - userId: string - threadTs?: string -} - -export interface Memory { - id?: number - category: 'user' | 'project' | 'fact' | 'preference' - key: string - content: string - tags?: string[] - source?: MemorySource - createdAt: string - updatedAt: string -} - -export interface SearchParams { - query: string - category?: Memory['category'] - tags?: string[] - channelId?: string -} diff --git a/packages/create-bot/templates/slack-bot/src/index.ts.tmpl b/packages/create-bot/templates/slack-bot/src/index.ts.tmpl deleted file mode 100644 index c3c1cc0..0000000 --- a/packages/create-bot/templates/slack-bot/src/index.ts.tmpl +++ /dev/null @@ -1,60 +0,0 @@ -import { createSlackApp } from './slack/app' -import { registerHandlers, registerWithEmulator } from './slack/handlers' -import { settings, isSimulatorMode } from './settings' -import { startConfigServer } from './config/http-server' -import { appLogger } from './utils/logger' - -async function main() { - appLogger.info( - { simulatorMode: isSimulatorMode }, - 'Starting {{botNamePascal}} Assistant...' - ) - - // Start config server for simulator (exposes /config endpoint) - if (isSimulatorMode) { - const configPort = settings.PORT + 1 - startConfigServer(configPort) - } - - const app = createSlackApp() - await registerHandlers(app) - await app.start() - - // Register with emulator AFTER WebSocket is connected - if (isSimulatorMode) { - await registerWithEmulator() - - // Re-register on reconnection (e.g., after simulator restart) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const receiver = (app as any).receiver as { client?: { on?: Function } } - receiver?.client?.on?.('connected', () => { - appLogger.info('WebSocket reconnected, re-registering with emulator...') - registerWithEmulator().catch((err) => { - appLogger.error({ err }, 'Failed to re-register with emulator') - }) - }) - } - - // Graceful shutdown handler - const shutdown = async () => { - appLogger.info('Shutting down...') - await app.stop() - appLogger.info('Shutdown complete') - process.exit(0) - } - - process.on('SIGTERM', shutdown) - process.on('SIGINT', shutdown) - - appLogger.info( -{{~#if isAi}} - { provider: settings.AI_PROVIDER }, -{{~/if}} - `${settings.BOT_NAME} is running!` - ) -} - -main().catch((error) => { - appLogger.fatal({ err: error }, 'Fatal error') - process.exit(1) -}) diff --git a/packages/create-bot/templates/slack-bot/src/listeners/assistant/context-changed.ts b/packages/create-bot/templates/slack-bot/src/listeners/assistant/context-changed.ts new file mode 100644 index 0000000..c7ea6f5 --- /dev/null +++ b/packages/create-bot/templates/slack-bot/src/listeners/assistant/context-changed.ts @@ -0,0 +1,12 @@ +import type { AssistantThreadContextChangedMiddleware } from '@slack/bolt' +import { slackLogger } from '../../utils/logger' + +export const assistantContextChanged: AssistantThreadContextChangedMiddleware = + async ({ saveThreadContext }) => { + try { + // Save the new context when user switches channels + await saveThreadContext() + } catch (error) { + slackLogger.error({ error }, 'Error in contextChanged handler') + } + } diff --git a/packages/create-bot/templates/slack-bot/src/listeners/assistant/index.ts b/packages/create-bot/templates/slack-bot/src/listeners/assistant/index.ts new file mode 100644 index 0000000..36784ea --- /dev/null +++ b/packages/create-bot/templates/slack-bot/src/listeners/assistant/index.ts @@ -0,0 +1,15 @@ +import { Assistant } from '@slack/bolt' +import type { App } from '@slack/bolt' +import { assistantThreadStarted } from './thread-started' +import { assistantContextChanged } from './context-changed' +import { assistantUserMessage } from './message' + +const assistant = new Assistant({ + threadStarted: assistantThreadStarted, + threadContextChanged: assistantContextChanged, + userMessage: assistantUserMessage, +}) + +export function register(app: App) { + app.assistant(assistant) +} diff --git a/packages/create-bot/templates/slack-bot/src/listeners/assistant/message.ts b/packages/create-bot/templates/slack-bot/src/listeners/assistant/message.ts new file mode 100644 index 0000000..cafc817 --- /dev/null +++ b/packages/create-bot/templates/slack-bot/src/listeners/assistant/message.ts @@ -0,0 +1,76 @@ +import type { AssistantUserMessageMiddleware } from '@slack/bolt' +import type { MessageElement } from '@slack/web-api/dist/types/response/ConversationsRepliesResponse' +import { responseHandler, type ThreadContext } from '../../response-handler' +import { slackLogger } from '../../utils/logger' + +export const assistantUserMessage: AssistantUserMessageMiddleware = async ({ + client, + message, + context, + say, + setTitle, + setStatus, +}) => { + // Validate message shape + if ( + !('text' in message) || + !('thread_ts' in message) || + !message.text || + !message.thread_ts + ) { + return + } + + const { channel, thread_ts } = message + const { userId, teamId } = context + + try { + // Set thread title to the user's message + await setTitle(message.text) + + // Show typing indicator + await setStatus('is thinking...') + + // Retrieve thread history for context + const thread = await client.conversations.replies({ + channel, + ts: thread_ts, + oldest: thread_ts, + }) + + // Build thread context for the response handler + const history = + thread.messages?.map((m: MessageElement) => ({ + role: (m.bot_id ? 'assistant' : 'user') as 'user' | 'assistant', + content: m.text || '', + })) ?? [] + + const threadContext: ThreadContext = { + channelId: channel, + threadTs: thread_ts, + userId: userId ?? '', + teamId: teamId ?? '', + history, + } + + // Generate and stream response + const streamer = client.chatStream({ + channel, + recipient_team_id: teamId, + recipient_user_id: userId, + thread_ts, + }) + + for await (const chunk of responseHandler.generateResponse( + message.text, + threadContext + )) { + await streamer.append({ markdown_text: chunk }) + } + + await streamer.stop() + } catch (error) { + slackLogger.error({ error }, 'Error in userMessage handler') + await say({ text: 'Sorry, something went wrong!' }) + } +} diff --git a/packages/create-bot/templates/slack-bot/src/listeners/assistant/thread-started.ts b/packages/create-bot/templates/slack-bot/src/listeners/assistant/thread-started.ts new file mode 100644 index 0000000..5692112 --- /dev/null +++ b/packages/create-bot/templates/slack-bot/src/listeners/assistant/thread-started.ts @@ -0,0 +1,45 @@ +import type { AssistantThreadStartedMiddleware } from '@slack/bolt' +import { responseHandler } from '../../response-handler' +import { slackLogger } from '../../utils/logger' + +export const assistantThreadStarted: AssistantThreadStartedMiddleware = async ({ + event, + say, + setSuggestedPrompts, + saveThreadContext, +}) => { + const { context } = event.assistant_thread + + try { + // Send initial greeting + await say('Hi, how can I help?') + + // Save thread context for future messages + await saveThreadContext() + + // Set suggested prompts from the response handler + const prompts = responseHandler.suggestedPrompts + if (prompts && prompts.length > 0) { + // Provide channel-specific prompts if in a channel context + if (context.channel_id) { + await setSuggestedPrompts({ + title: 'Here are some things I can help with:', + prompts: [ + { + title: 'Summarize channel', + message: 'Please summarize the recent activity in this channel.', + }, + ...prompts, + ], + }) + } else { + await setSuggestedPrompts({ + title: 'Try these prompts:', + prompts, + }) + } + } + } catch (error) { + slackLogger.error({ error }, 'Error in threadStarted handler') + } +} diff --git a/packages/create-bot/templates/slack-bot/src/listeners/events/app-mention.ts b/packages/create-bot/templates/slack-bot/src/listeners/events/app-mention.ts new file mode 100644 index 0000000..f4c643d --- /dev/null +++ b/packages/create-bot/templates/slack-bot/src/listeners/events/app-mention.ts @@ -0,0 +1,60 @@ +import type { AllMiddlewareArgs, SlackEventMiddlewareArgs } from '@slack/bolt' +import type { AppMentionEvent } from '@slack/types' +import { responseHandler, type ThreadContext } from '../../response-handler' +import { slackConfig } from '../../config/loader' +import { slackLogger } from '../../utils/logger' + +type AppMentionArgs = AllMiddlewareArgs & SlackEventMiddlewareArgs<'app_mention'> + +export async function appMention({ event, client, say }: AppMentionArgs) { + slackLogger.info( + { user: event.user, channel: event.channel }, + 'Mention received' + ) + + // Strip bot mention from message text + const text = event.text + .replace(/<@[A-Z0-9]+(\|[^>]*)?>/g, '') + .replace(new RegExp(`@?${slackConfig.app.name}`, 'gi'), '') + .replace(new RegExp(`@?${slackConfig.app.id ?? ''}`, 'gi'), '') + .trim() + + const threadTs = event.thread_ts || event.ts + + // If no text after mention, send greeting (fast response) + if (!text) { + await say({ text: 'Hi! How can I help you?', thread_ts: threadTs }) + return + } + + // Process asynchronously to ack within 3 seconds + processMention(say, event, text, threadTs) +} + +async function processMention( + say: (msg: { text: string; thread_ts: string }) => Promise, + event: AppMentionEvent, + text: string, + threadTs: string +) { + try { + const threadContext: ThreadContext = { + channelId: event.channel, + threadTs: threadTs, + userId: event.user ?? '', + teamId: '', + history: [], + } + + // Generate response + let response = '' + for await (const chunk of responseHandler.generateResponse(text, threadContext)) { + response += chunk + } + + await say({ text: response, thread_ts: threadTs }) + } catch (error) { + slackLogger.error({ error }, 'Error handling app_mention') + await say({ text: 'Sorry, something went wrong!', thread_ts: threadTs }) + } +} diff --git a/packages/create-bot/templates/slack-bot/src/listeners/events/index.ts b/packages/create-bot/templates/slack-bot/src/listeners/events/index.ts new file mode 100644 index 0000000..04b4ec6 --- /dev/null +++ b/packages/create-bot/templates/slack-bot/src/listeners/events/index.ts @@ -0,0 +1,6 @@ +import type { App } from '@slack/bolt' +import { appMention } from './app-mention' + +export function register(app: App) { + app.event('app_mention', appMention) +} diff --git a/packages/create-bot/templates/slack-bot/src/listeners/index.ts b/packages/create-bot/templates/slack-bot/src/listeners/index.ts new file mode 100644 index 0000000..f0fff0a --- /dev/null +++ b/packages/create-bot/templates/slack-bot/src/listeners/index.ts @@ -0,0 +1,10 @@ +import type { App } from '@slack/bolt' +import * as assistant from './assistant/index' +import * as events from './events/index' +import * as messages from './messages/index' + +export function registerListeners(app: App) { + assistant.register(app) + events.register(app) + messages.register(app) +} diff --git a/packages/create-bot/templates/slack-bot/src/listeners/messages/index.ts b/packages/create-bot/templates/slack-bot/src/listeners/messages/index.ts new file mode 100644 index 0000000..863977b --- /dev/null +++ b/packages/create-bot/templates/slack-bot/src/listeners/messages/index.ts @@ -0,0 +1,72 @@ +import type { App } from '@slack/bolt' +import type { GenericMessageEvent } from '@slack/types' +import { responseHandler, type ThreadContext } from '../../response-handler' +import { slackLogger } from '../../utils/logger' + +export function register(app: App) { + // Handle direct messages + app.event('message', async ({ event, client, say }) => { + // Filter to only handle DMs with text + if (!('channel_type' in event) || !('user' in event) || !('text' in event)) { + return + } + + const messageEvent = event as GenericMessageEvent + + // Only handle DMs (im = instant message) + if (messageEvent.channel_type !== 'im') { + return + } + + // Skip bot messages and message subtypes (edits, deletes, etc.) + if (messageEvent.subtype || messageEvent.bot_id) { + return + } + + const text = messageEvent.text?.trim() + if (!text) return + + // Only use thread_ts if user is replying in an existing thread + const isThreadReply = Boolean(messageEvent.thread_ts) + const threadTs = messageEvent.thread_ts || messageEvent.ts + + slackLogger.info({ user: messageEvent.user, channel: messageEvent.channel }, 'DM received') + + // Process asynchronously to ack within 3 seconds + processMessage(say, text, messageEvent, isThreadReply, threadTs) + }) +} + +async function processMessage( + say: (msg: string | { text: string; thread_ts?: string }) => Promise, + text: string, + messageEvent: GenericMessageEvent, + isThreadReply: boolean, + threadTs: string +) { + try { + const threadContext: ThreadContext = { + channelId: messageEvent.channel, + threadTs: threadTs, + userId: messageEvent.user ?? '', + teamId: '', + history: [], + } + + // Generate response + let response = '' + for await (const chunk of responseHandler.generateResponse(text, threadContext)) { + response += chunk + } + + // In DMs: reply inline unless user is in a thread + if (isThreadReply) { + await say({ text: response, thread_ts: threadTs }) + } else { + await say({ text: response }) + } + } catch (error) { + slackLogger.error({ error }, 'Error handling DM') + await say({ text: 'Sorry, something went wrong!' }) + } +} diff --git a/packages/create-bot/templates/slack-bot/src/memory/store.ts b/packages/create-bot/templates/slack-bot/src/memory/store.ts deleted file mode 100644 index e032bec..0000000 --- a/packages/create-bot/templates/slack-bot/src/memory/store.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { getRepository } from '../db' -import type { Memory, MemorySource, SearchParams } from '../db' -import { memoryLogger } from '../utils/logger' - -export type { Memory, MemorySource } - -export interface StoreSearchParams { - query: string - category?: Memory['category'] - tags?: string[] - channelId?: string -} - -type MemoryInput = Omit - -export class MemoryStore { - private formatKey(category: string, key: string): string { - return `${category}/${key}` - } - - async store(memory: MemoryInput): Promise { - const keyStr = this.formatKey(memory.category, memory.key) - memoryLogger.debug(`Storing: ${keyStr}`) - - const repo = await getRepository() - await repo.store(memory) - - memoryLogger.debug(`Stored successfully: ${keyStr}`) - } - - async retrieve( - category: Memory['category'], - key: string - ): Promise { - const keyStr = this.formatKey(category, key) - memoryLogger.debug(`Retrieving: ${keyStr}`) - - const repo = await getRepository() - const memory = await repo.retrieve(category, key) - - memoryLogger.debug(`Retrieved: ${memory ? 'found' : 'not found'}`) - return memory - } - - async search(params: StoreSearchParams): Promise { - memoryLogger.debug( - `Searching: query="${params.query}", category=${params.category ?? 'any'}` - ) - - const repo = await getRepository() - const searchParams: SearchParams = { - query: params.query, - category: params.category, - tags: params.tags, - channelId: params.channelId, - } - const results = await repo.search(searchParams) - - memoryLogger.debug(`Search found ${results.length} results`) - return results - } - - async delete(category: Memory['category'], key: string): Promise { - const keyStr = this.formatKey(category, key) - memoryLogger.debug(`Deleting: ${keyStr}`) - - const repo = await getRepository() - const deleted = await repo.delete(category, key) - - memoryLogger.debug( - `${deleted ? 'Deleted' : 'Not found for deletion'}: ${keyStr}` - ) - return deleted - } - - async list(category?: Memory['category']): Promise { - memoryLogger.debug(`Listing: category=${category ?? 'all'}`) - - const repo = await getRepository() - const results = await repo.list(category) - - memoryLogger.debug(`Listed ${results.length} memories`) - return results - } -} - -export const memoryStore = new MemoryStore() diff --git a/packages/create-bot/templates/slack-bot/src/preferences/index.ts b/packages/create-bot/templates/slack-bot/src/preferences/index.ts deleted file mode 100644 index 6679b63..0000000 --- a/packages/create-bot/templates/slack-bot/src/preferences/index.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { memoryStore } from '../memory/store' -import { getErrorMessage } from '../utils/error' -import { preferencesLogger } from '../utils/logger' - -export interface UserPreferences { - responseStyle: 'concise' | 'detailed' | 'balanced' - language: string - timezone: string -} - -export const DEFAULT_PREFERENCES: UserPreferences = { - responseStyle: 'balanced', - language: 'en', - timezone: 'UTC', -} - -const PREFERENCE_KEY_SUFFIX = ':preferences' - -const lockChains = new Map>() - -async function withLock(userId: string, fn: () => Promise): Promise { - const currentChain = lockChains.get(userId) ?? Promise.resolve() - - const run = currentChain.catch(() => undefined).then(fn) - const chain: Promise = run.then( - () => {}, - () => {} - ) - lockChains.set(userId, chain) - try { - return await run - } finally { - if (lockChains.get(userId) === chain) { - lockChains.delete(userId) - } - } -} - -export async function getUserPreferences( - userId: string -): Promise { - preferencesLogger.debug({ userId }, 'Loading preferences') - - const defaults = { ...DEFAULT_PREFERENCES } - - const key = `${userId}${PREFERENCE_KEY_SUFFIX}` - const stored = await memoryStore.retrieve('preference', key) - - if (stored) { - try { - const savedPrefs = JSON.parse(stored.content) as Partial - preferencesLogger.debug({ prefs: savedPrefs }, 'Loaded saved preferences') - return { ...defaults, ...savedPrefs } - } catch (error) { - preferencesLogger.error( - { err: error }, - 'Failed to parse stored preferences' - ) - } - } - - preferencesLogger.debug({ defaults }, 'Using defaults') - return defaults -} - -export async function saveUserPreferences( - userId: string, - prefs: Partial -): Promise { - preferencesLogger.debug({ userId, prefs }, 'Saving preferences') - - await withLock(userId, async () => { - const key = `${userId}${PREFERENCE_KEY_SUFFIX}` - - const stored = await memoryStore.retrieve('preference', key) - let existingPrefs: Partial = {} - - if (stored) { - try { - existingPrefs = JSON.parse(stored.content) - } catch { - // Ignore parse errors, start fresh - } - } - - const merged = { ...existingPrefs, ...prefs } - - await memoryStore.store({ - category: 'preference', - key, - content: JSON.stringify(merged), - tags: ['user-preference'], - }) - - preferencesLogger.debug('Saved successfully') - }) -} - -const RESPONSE_STYLE_ALIASES: Record = - { - concise: 'concise', - brief: 'concise', - short: 'concise', - detailed: 'detailed', - verbose: 'detailed', - long: 'detailed', - balanced: 'balanced', - normal: 'balanced', - default: 'balanced', - } - -export function normalizeResponseStyle( - value: string -): UserPreferences['responseStyle'] | null { - return RESPONSE_STYLE_ALIASES[value.toLowerCase().trim()] ?? null -} - -export function normalizeTimezone(value: string): string | null { - try { - Intl.DateTimeFormat(undefined, { timeZone: value }) - return value - } catch { - return null - } -} diff --git a/packages/create-bot/templates/slack-bot/src/response-handler.ts.tmpl b/packages/create-bot/templates/slack-bot/src/response-handler.ts.tmpl new file mode 100644 index 0000000..1fcdd49 --- /dev/null +++ b/packages/create-bot/templates/slack-bot/src/response-handler.ts.tmpl @@ -0,0 +1,76 @@ +/** + * Response Handler + * + * This is the main file to customize bot responses. + */ +{{#if isAi}} +import { streamText } from 'ai' +import { getModel, settings } from './settings' +{{/if}} + +export interface ThreadContext { + channelId: string + threadTs: string + userId: string + teamId: string + history: Array<{ role: 'user' | 'assistant'; content: string }> +} + +export interface SuggestedPrompt { + title: string + message: string +} + +export interface ResponseHandler { + generateResponse( + message: string, + context: ThreadContext + ): AsyncIterable + systemPrompt?: string + suggestedPrompts?: SuggestedPrompt[] +} + +export const responseHandler: ResponseHandler = { + systemPrompt: '{{#if isAi}}You are a helpful assistant. Be concise and friendly.{{else}}You are a helpful assistant.{{/if}}', + + suggestedPrompts: [ + { title: 'Say hello', message: 'Hello!' }, + { title: 'Get help', message: 'What can you help me with?' }, + ], + + async *generateResponse(message, context) { + // Handle ping command + if (message.toLowerCase() === 'ping') { + yield 'pong' + return + } +{{#if isAi}} + + // Build messages from history + const messages = context.history + .filter(m => m.content.trim()) + .map(m => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })) + + // Add current message + messages.push({ role: 'user', content: message }) + + // Stream response from AI + const result = streamText({ + model: getModel(), + system: this.systemPrompt, + messages, + }) + + for await (const chunk of result.textStream) { + yield chunk + } +{{else}} + + // Echo response - replace with your logic + yield `You said: ${message}` +{{/if}} + }, +} diff --git a/packages/create-bot/templates/slack-bot/src/settings.ts.tmpl b/packages/create-bot/templates/slack-bot/src/settings.ts.tmpl index fbe762d..728848d 100644 --- a/packages/create-bot/templates/slack-bot/src/settings.ts.tmpl +++ b/packages/create-bot/templates/slack-bot/src/settings.ts.tmpl @@ -1,40 +1,13 @@ import { z } from 'zod' -{{~#if isOpenai}} +{{#if isOpenai}} import { openai } from '@ai-sdk/openai' -{{~/if}} -{{~#if isAnthropic}} +{{/if}} +{{#if isAnthropic}} import { anthropic } from '@ai-sdk/anthropic' -{{~/if}} -{{~#if isGoogle}} +{{/if}} +{{#if isGoogle}} import { google } from '@ai-sdk/google' -{{~/if}} -{{~#if isAi}} - -// Model tiers - customize as needed -export const MODEL_TIERS = { -{{~#if isOpenai}} - openai: { - fast: ['gpt-4o-mini'], - default: ['gpt-4o'], - }, -{{~/if}} -{{~#if isAnthropic}} - anthropic: { - fast: ['claude-3-5-haiku-latest'], - default: ['claude-sonnet-4-5'], - }, -{{~/if}} -{{~#if isGoogle}} - google: { - fast: ['gemini-2.0-flash'], - default: ['gemini-2.5-pro'], - }, -{{~/if}} -} - -export type AIProvider = keyof typeof MODEL_TIERS -export type ModelTier = 'fast' | 'default' -{{~/if}} +{{/if}} // Local simulator mode (SLACK_API_URL set) export const isSimulatorMode = Boolean(process.env.SLACK_API_URL) @@ -55,29 +28,26 @@ const envSchema = z.object({ SLACK_SIGNING_SECRET: isLocalMode ? z.string().default('local') : z.string().min(1), -{{~#if isAi}} +{{#if isAi}} // AI Provider AI_PROVIDER: z.enum(['{{aiProvider}}']).default('{{aiProvider}}'), -{{~#if isOpenai}} - OPENAI_API_KEY: z.string().min(1), -{{~/if}} -{{~#if isAnthropic}} - ANTHROPIC_API_KEY: z.string().min(1), -{{~/if}} -{{~#if isGoogle}} - GOOGLE_API_KEY: z.string().min(1), -{{~/if}} -{{~/if}} -{{~#if isDb}} - - // Database - DB_ADAPTER: z.enum(['{{dbAdapter}}']).default('{{dbAdapter}}'), -{{~#if isPostgres}} - DATABASE_URL: z.string().url(), -{{~/if}} - DATA_DIR: z.string().default('./data'), -{{~/if}} +{{#if isOpenai}} + OPENAI_API_KEY: isLocalMode + ? z.string().default('sk-local') + : z.string().min(1), +{{/if}} +{{#if isAnthropic}} + ANTHROPIC_API_KEY: isLocalMode + ? z.string().default('sk-local') + : z.string().min(1), +{{/if}} +{{#if isGoogle}} + GOOGLE_API_KEY: isLocalMode + ? z.string().default('sk-local') + : z.string().min(1), +{{/if}} +{{/if}} // Server PORT: z.coerce.number().default(3000), @@ -101,29 +71,18 @@ function loadSettings(): Settings { } export const settings = loadSettings() -{{~#if isAi}} - -// Model helpers -function getModelForTier(tier: ModelTier) { - const provider = settings.AI_PROVIDER - const models = MODEL_TIERS[provider][tier] - const modelId = models[0]! -{{~#if isOpenai}} - return openai(modelId) -{{~/if}} -{{~#if isAnthropic}} - return anthropic(modelId) -{{~/if}} -{{~#if isGoogle}} - return google(modelId) -{{~/if}} -} +{{#if isAi}} +// AI Model helper export function getModel() { - return getModelForTier('default') -} - -export function getFastModel() { - return getModelForTier('fast') +{{#if isOpenai}} + return openai('gpt-4o') +{{/if}} +{{#if isAnthropic}} + return anthropic('claude-sonnet-4-20250514') +{{/if}} +{{#if isGoogle}} + return google('gemini-2.0-flash') +{{/if}} } -{{~/if}} +{{/if}} diff --git a/packages/create-bot/templates/slack-bot/src/slack/app.ts b/packages/create-bot/templates/slack-bot/src/slack/app.ts deleted file mode 100644 index 2efc231..0000000 --- a/packages/create-bot/templates/slack-bot/src/slack/app.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { App, LogLevel } from '@slack/bolt' -import { settings } from '../settings' - -const isLocalMode = Boolean(process.env.SLACK_API_URL) - -// Simulator tokens for local mode -const SIMULATOR_BOT_TOKEN = 'xoxb-simulator-token' -const SIMULATOR_APP_TOKEN = 'xapp-simulator-token' - -export function createSlackApp() { - const logLevel = - settings.LOG_LEVEL === 'debug' ? LogLevel.DEBUG : LogLevel.INFO - - if (isLocalMode) { - console.log( - `[Slack] Connecting to emulator at ${process.env.SLACK_API_URL}` - ) - return new App({ - token: SIMULATOR_BOT_TOKEN, - appToken: SIMULATOR_APP_TOKEN, - socketMode: true, - logLevel, - clientOptions: { - slackApiUrl: process.env.SLACK_API_URL, - }, - }) - } - - return new App({ - token: settings.SLACK_BOT_TOKEN, - appToken: settings.SLACK_APP_TOKEN, - socketMode: true, - logLevel, - }) -} - -export type SlackApp = ReturnType diff --git a/packages/create-bot/templates/slack-bot/src/slack/handlers.ts.tmpl b/packages/create-bot/templates/slack-bot/src/slack/handlers.ts.tmpl deleted file mode 100644 index ea60da4..0000000 --- a/packages/create-bot/templates/slack-bot/src/slack/handlers.ts.tmpl +++ /dev/null @@ -1,327 +0,0 @@ -import type { SlackApp } from './app' -import type { WebClient } from '@slack/web-api' -import type { GenericMessageEvent } from '@slack/types' -{{#if isAi}} -import { chatWithHistory } from '../ai/chat' -{{/if}} -import { threadTracker } from './thread-tracker' -import { settings } from '../settings' -import { slackConfig } from '../config/loader' -import { slackLogger } from '../utils/logger' - -let botUserId: string | null = null - -// Track messages being processed to prevent duplicates -const processingMessages = new Map() -const PROCESSING_TTL_MS = 30000 - -function isMessageBeingProcessed(channel: string, ts: string): boolean { - const key = `${channel}:${ts}` - const startTime = processingMessages.get(key) - return startTime !== undefined && Date.now() - startTime < PROCESSING_TTL_MS -} - -function markMessageProcessing(channel: string, ts: string): void { - const key = `${channel}:${ts}` - processingMessages.set(key, Date.now()) - // Cleanup old entries - for (const [k, time] of processingMessages) { - if (Date.now() - time > PROCESSING_TTL_MS) { - processingMessages.delete(k) - } - } -} -{{#if isAi}} - -type ChatMessage = { role: 'user' | 'assistant'; content: string } -type SlackMessage = { text?: string; user?: string; subtype?: string } -{{/if}} - -const REACTION = { - thinking: 'thinking_face', - done: 'white_check_mark', -} as const - -async function addReaction( - client: WebClient, - channel: string, - timestamp: string, - name: string -) { - try { - await client.reactions.add({ channel, timestamp, name }) - } catch { - // Ignore reaction errors - } -} - -async function removeReaction( - client: WebClient, - channel: string, - timestamp: string, - name: string -) { - try { - await client.reactions.remove({ channel, timestamp, name }) - } catch { - // Ignore reaction errors - } -} -{{#if isAi}} - -function transformMessages(messages: SlackMessage[]): ChatMessage[] { - return messages - .filter((msg): msg is SlackMessage & { text: string } => - Boolean(msg.text && !msg.subtype) - ) - .map((msg) => ({ - role: (msg.user === botUserId ? 'assistant' : 'user') as ChatMessage['role'], - content: msg.text.replace(/<@[A-Z0-9]+(\|[^>]*)?>/g, '').trim(), - })) - .filter((msg) => msg.content) -} - -async function getThreadHistory( - client: WebClient, - channel: string, - threadTs: string -): Promise { - try { - const result = await client.conversations.replies({ channel, ts: threadTs }) - return transformMessages(result.messages ?? []) - } catch (error) { - slackLogger.error({ err: error }, 'Failed to fetch thread history') - return [] - } -} -{{/if}} - -async function hasBotParticipatedInThread( - client: WebClient, - channel: string, - threadTs: string -): Promise { - try { - const result = await client.conversations.replies({ - channel, - ts: threadTs, - limit: 100, - }) - return result.messages?.some((msg) => msg.user === botUserId) ?? false - } catch { - return false - } -} - -export async function registerHandlers(app: SlackApp) { - // Get bot user ID (skip in local simulator mode) - if (process.env.SLACK_API_URL) { - botUserId = 'BOT_LOCAL' - slackLogger.info({ botUserId }, 'Local mode - using placeholder bot ID') - // Note: registerWithEmulator() is called from index.ts AFTER app.start() - // to ensure WebSocket is connected before HTTP registration - } else { - const authResult = await app.client.auth.test() - botUserId = authResult.user_id || null - slackLogger.info({ botUserId }, 'Bot user ID initialized') - } - - threadTracker.startCleanup() - - const botName = settings.BOT_NAME - - // Handle @mentions in channels - app.event('app_mention', async ({ event, say }) => { - slackLogger.info({ user: event.user, channel: event.channel }, 'Mention received') - - if (isMessageBeingProcessed(event.channel, event.ts)) { - return - } - markMessageProcessing(event.channel, event.ts) - - const text = event.text - .replace(/<@[A-Z0-9]+(\|[^>]*)?>/g, '') - .replace(new RegExp(`@?${slackConfig.app.name}`, 'gi'), '') - .replace(new RegExp(`@?${slackConfig.app.id ?? ''}`, 'gi'), '') - .trim() - - if (!text) { - const threadTs = event.thread_ts || event.ts - await say({ text: 'Hi! How can I help you?', thread_ts: threadTs }) - threadTracker.mark(event.channel, threadTs) - return - } - - await handleResponse( - app, - { channel: event.channel, ts: event.ts, thread_ts: event.thread_ts, user: event.user! }, - text, - botName, - say - ) - }) - - // Handle direct messages and thread replies - app.event('message', async ({ event, say }) => { - if (!('channel_type' in event) || !('user' in event) || !('text' in event)) { - return - } - - const messageEvent = event as GenericMessageEvent - - if (messageEvent.subtype || !botUserId || messageEvent.user === botUserId) { - return - } - - const text = messageEvent.text?.trim() ?? '' - if (!text) return - - if (isMessageBeingProcessed(messageEvent.channel, messageEvent.ts)) { - return - } - - // Handle DMs - if (messageEvent.channel_type === 'im') { - slackLogger.info({ user: messageEvent.user }, 'DM received') - markMessageProcessing(messageEvent.channel, messageEvent.ts) - await handleResponse(app, messageEvent, text, botName, say, 'im') - return - } - - // Handle channel messages - if (messageEvent.channel_type === 'channel' || messageEvent.channel_type === 'group') { - // Skip @mentions (handled by app_mention) - if (botUserId && text.includes(`<@${botUserId}>`)) { - return - } - - // Only respond in threads where bot is participating - const isThreadReply = Boolean(messageEvent.thread_ts) - if (!isThreadReply) return - - let shouldRespond = threadTracker.isParticipating( - messageEvent.channel, - messageEvent.thread_ts! - ) - - if (!shouldRespond) { - shouldRespond = await hasBotParticipatedInThread( - app.client, - messageEvent.channel, - messageEvent.thread_ts! - ) - if (shouldRespond) { - threadTracker.mark(messageEvent.channel, messageEvent.thread_ts!) - } - } - - if (shouldRespond) { - markMessageProcessing(messageEvent.channel, messageEvent.ts) - await handleResponse(app, messageEvent, text, botName, say) - } - } - }) - - slackLogger.info('Handlers registered') -} - -async function handleResponse( - app: SlackApp, - event: { channel: string; ts: string; thread_ts?: string; user: string }, - text: string, - botName: string, - say: (msg: { text: string; thread_ts?: string }) => Promise, - channelType: string = 'channel' -) { - const isDM = channelType === 'im' - const { channel, ts, user, thread_ts } = event - const threadTs = thread_ts || ts - - await addReaction(app.client, channel, ts, REACTION.thinking) - - // Test response for ping - if (text.toLowerCase() === 'ping') { - await say(isDM ? { text: 'pong' } : { text: 'pong', thread_ts: threadTs }) - await removeReaction(app.client, channel, ts, REACTION.thinking) - await addReaction(app.client, channel, ts, REACTION.done) - return - } - -{{#if isAi}} - try { - const messages = isDM ? [] : await getThreadHistory(app.client, channel, threadTs) - - if (messages.length === 0 || messages[messages.length - 1]?.content !== text) { - messages.push({ role: 'user', content: text }) - } - - const response = await chatWithHistory(messages, { - channel, - thread_ts: isDM ? undefined : threadTs, - user, - botName, - }) - - await say(isDM ? { text: response } : { text: response, thread_ts: threadTs }) - - if (!isDM) { - threadTracker.mark(channel, threadTs) - } - - await removeReaction(app.client, channel, ts, REACTION.thinking) - await addReaction(app.client, channel, ts, REACTION.done) - } catch (error) { - slackLogger.error({ err: error }, 'Error handling message') - await removeReaction(app.client, channel, ts, REACTION.thinking) - - const errorMsg = 'Sorry, I encountered an error processing your request.' - await say(isDM ? { text: errorMsg } : { text: errorMsg, thread_ts: threadTs }) - } -{{else}} - // TODO: Add your message handling logic here - // For now, ignore messages that aren't handled above - await removeReaction(app.client, channel, ts, REACTION.thinking) -{{/if}} -} - -/** - * Register app config with the Botarium emulator. - * This allows the emulator to discover and display the bot. - * Must be called AFTER app.start() to ensure WebSocket is connected. - * Retries if the emulator isn't ready yet. - */ -export async function registerWithEmulator( - maxRetries = 30, - retryDelayMs = 2000 -): Promise { - const apiUrl = process.env.SLACK_API_URL - if (!apiUrl) return - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - // Register full app config (includes commands, shortcuts, etc.) - const response = await fetch(`${apiUrl}/api/config/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(slackConfig), - }) - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) - } - - slackLogger.info('Registered with emulator') - return - } catch (error) { - if (attempt < maxRetries) { - slackLogger.debug( - { attempt, maxRetries }, - 'Emulator not ready, retrying...' - ) - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)) - } else { - slackLogger.warn({ err: error }, 'Failed to register with emulator') - } - } - } -} diff --git a/packages/create-bot/templates/slack-bot/src/slack/thread-tracker.ts b/packages/create-bot/templates/slack-bot/src/slack/thread-tracker.ts deleted file mode 100644 index 1485ec6..0000000 --- a/packages/create-bot/templates/slack-bot/src/slack/thread-tracker.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Thread participation tracker for bot call detection. - * Tracks threads where the bot has participated to enable - * auto-responding without explicit mentions. - */ - -export interface ThreadTrackerConfig { - /** TTL for thread entries in milliseconds (default: 24 hours) */ - ttlMs?: number - /** Cleanup interval in milliseconds (default: 1 hour) */ - cleanupIntervalMs?: number -} - -const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000 -const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000 - -export class ThreadTracker { - private threads = new Map() - private cleanupTimer: ReturnType | null = null - private readonly ttlMs: number - private readonly cleanupIntervalMs: number - - constructor(config: ThreadTrackerConfig = {}) { - this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS - this.cleanupIntervalMs = - config.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS - } - - private getKey(channel: string, threadTs: string): string { - return `${channel}:${threadTs}` - } - - mark(channel: string, threadTs: string): void { - const key = this.getKey(channel, threadTs) - this.threads.set(key, Date.now()) - } - - isParticipating(channel: string, threadTs: string): boolean { - const key = this.getKey(channel, threadTs) - const lastActivity = this.threads.get(key) - - if (lastActivity === undefined) { - return false - } - - if (Date.now() - lastActivity > this.ttlMs) { - this.threads.delete(key) - return false - } - - return true - } - - cleanup(): void { - const now = Date.now() - for (const [key, lastActivity] of this.threads) { - if (now - lastActivity > this.ttlMs) { - this.threads.delete(key) - } - } - } - - get count(): number { - return this.threads.size - } - - startCleanup(): void { - if (!this.cleanupTimer) { - this.cleanupTimer = setInterval( - () => this.cleanup(), - this.cleanupIntervalMs - ) - } - } - - stopCleanup(): void { - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer) - this.cleanupTimer = null - } - } -} - -export const threadTracker = new ThreadTracker() From 8e2f2afb1b9c9853913a17a6e838b425417a1792 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sat, 17 Jan 2026 17:51:18 +0000 Subject: [PATCH 02/90] Fix WebSocket registration timing and AI error handling - Add waitForWebSocketConnection helper to verify emulator connection - Add IPC proxy for bot config fetch to bypass Electron CSP - Fix DynamicSettings to properly restore values on reload - Add user-friendly error messages for invalid API keys - Extract AI streaming logic to separate ai/ folder in template - Make ai/ folder conditional based on template options - Update default entry point to src/app.ts - Add test script and fix template test file --- apps/electron/bots.yaml | 9 +- apps/electron/electron.js | 118 +++++++++++++++--- apps/electron/package.json | 4 +- apps/electron/scripts/compile-bots.ts | 2 +- apps/electron/src/preload.ts | 4 + apps/ui/src/components/DynamicSettings.svelte | 24 +++- apps/ui/src/lib/backend-state.svelte.ts | 4 +- apps/ui/src/lib/config-client.ts | 20 ++- apps/ui/src/lib/electron-api.ts | 3 + bunfig.toml | 2 + package.json | 1 + packages/create-bot/src/scaffold.ts | 5 + .../templates/slack-bot/bunfig.toml | 2 + .../templates/slack-bot/src/ai/stream.ts.tmpl | 84 +++++++++++++ .../templates/slack-bot/src/app.ts.tmpl | 96 +++++++++++--- .../slack-bot/src/config/http-server.ts.tmpl | 3 + .../slack-bot/src/listeners/messages/index.ts | 19 ++- .../src/response-handler.test.ts.tmpl | 30 +++++ .../slack-bot/src/response-handler.ts.tmpl | 10 +- .../templates/slack-bot/src/utils/logger.ts | 11 +- .../templates/slack-bot/test/setup.ts | 2 + packages/slack/src/server/web-api.ts | 11 +- 22 files changed, 403 insertions(+), 61 deletions(-) create mode 100644 bunfig.toml create mode 100644 packages/create-bot/templates/slack-bot/bunfig.toml create mode 100644 packages/create-bot/templates/slack-bot/src/ai/stream.ts.tmpl create mode 100644 packages/create-bot/templates/slack-bot/src/response-handler.test.ts.tmpl create mode 100644 packages/create-bot/templates/slack-bot/test/setup.ts diff --git a/apps/electron/bots.yaml b/apps/electron/bots.yaml index 989a4f8..3deaa15 100644 --- a/apps/electron/bots.yaml +++ b/apps/electron/bots.yaml @@ -4,12 +4,15 @@ # Each bot needs: # - name: Unique identifier for the bot (used in dist/bots/{name}) # - source: Path to the bot's source directory (relative or absolute) -# - entry: (optional) Entry point file, defaults to src/index.ts +# - entry: (optional) Entry point file, defaults to src/app.ts # # Example: # bots: # - name: my-bot # source: /path/to/my-bot -# # entry: src/index.ts # default -bots: [] +bots: + # - name: bud + # source: ../../bud + - name: ai-bot + source: ../../ai-bot diff --git a/apps/electron/electron.js b/apps/electron/electron.js index 1b149d8..490f3e9 100644 --- a/apps/electron/electron.js +++ b/apps/electron/electron.js @@ -270,25 +270,36 @@ function getBotConfigs() { const botsConfig = readBotsConfig() const isPackaged = app.isPackaged + electronLogger.info( + { botsConfig, useDevServer, useBundledBots, isPackaged }, + 'getBotConfigs called' + ) + if (botsConfig.length === 0) { + electronLogger.info('No bots in config') return [] } if (useDevServer && !useBundledBots) { // Dev mode: run from source - return botsConfig - .map((bot) => { - const sourcePath = path.resolve(__dirname, bot.source) - const entry = bot.entry || 'src/index.ts' - return { - type: 'bun', - bunPath: 'bun', - script: path.join(sourcePath, entry), - cwd: sourcePath, - name: bot.name, - } - }) - .filter((config) => fs.existsSync(config.script)) + const configs = botsConfig.map((bot) => { + const sourcePath = path.resolve(__dirname, bot.source) + const entry = bot.entry || 'src/app.ts' + const script = path.join(sourcePath, entry) + const exists = fs.existsSync(script) + electronLogger.info( + { bot: bot.name, sourcePath, entry, script, exists }, + 'Bot config' + ) + return { + type: 'bun', + bunPath: 'bun', + script, + cwd: sourcePath, + name: bot.name, + } + }) + return configs.filter((config) => fs.existsSync(config.script)) } // Bundled/production mode: use compiled binaries @@ -310,13 +321,24 @@ function getBotConfigs() { // Settings management function loadSettings() { + electronLogger.debug({ settingsPath }, 'Loading settings') try { if (fs.existsSync(settingsPath)) { const data = fs.readFileSync(settingsPath, 'utf-8') const settings = JSON.parse(data) + electronLogger.debug( + { keys: Object.keys(settings).filter((k) => !k.startsWith('_')) }, + 'Settings loaded from file' + ) const decrypted = decryptSensitiveFields(settings) + // Log which fields were decrypted (without values) + const decryptedFields = Object.keys(settings).filter( + (k) => k.endsWith('_encrypted') && settings[k] === true + ) + electronLogger.debug({ decryptedFields }, 'Decrypted sensitive fields') return decrypted } + electronLogger.debug('No settings file found') } catch (err) { electronLogger.error({ err }, 'Failed to load settings') } @@ -324,10 +346,23 @@ function loadSettings() { } function saveSettings(settings) { + electronLogger.debug( + { + settingsPath, + keys: Object.keys(settings).filter((k) => !k.startsWith('_')), + }, + 'Saving settings' + ) try { fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) const encrypted = encryptSensitiveFields(settings) + // Log which fields were encrypted (without values) + const encryptedFields = Object.keys(encrypted).filter( + (k) => k.endsWith('_encrypted') && encrypted[k] === true + ) + electronLogger.debug({ encryptedFields }, 'Encrypted sensitive fields') fs.writeFileSync(settingsPath, JSON.stringify(encrypted, null, 2)) + electronLogger.info('Settings saved successfully') } catch (err) { electronLogger.error({ err }, 'Failed to save settings') throw err @@ -756,14 +791,25 @@ async function waitForBotConnection(retries = 20, delay = 500) { // IPC Handlers function setupIpcHandlers() { ipcMain.handle('settings:load', () => { - return loadSettings() + electronLogger.debug('IPC: settings:load called') + const settings = loadSettings() + electronLogger.debug( + { hasSettings: !!settings, hasApiKey: !!settings?.openai_api_key }, + 'IPC: settings:load returning' + ) + return settings }) ipcMain.handle('settings:save', async (_event, settings) => { + electronLogger.debug( + { hasApiKey: !!settings?.openai_api_key }, + 'IPC: settings:save called' + ) saveSettings(settings) // Restart backend with new settings await stopBackend() await startBackend(settings) + electronLogger.debug('IPC: settings:save completed') }) ipcMain.handle('backend:restart', async () => { @@ -787,6 +833,35 @@ function setupIpcHandlers() { ipcMain.on('logs-panel:state-changed', (_event, visible) => { updateLogsPanelMenuState(visible) }) + + // Fetch bot config (proxied to avoid renderer CSP issues) + // Includes retry logic since bot config server may not be immediately available + ipcMain.handle('bot:fetchConfig', async () => { + const CONFIG_PORT = 3001 + const MAX_RETRIES = 10 + const RETRY_DELAY = 300 + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const response = await fetch(`http://127.0.0.1:${CONFIG_PORT}/config`) + if (response.ok) { + const data = await response.json() + electronLogger.debug({ attempt }, 'Bot config fetched successfully') + return data + } + electronLogger.debug({ status: response.status, attempt }, 'Config server returned error') + } catch (error) { + electronLogger.debug({ error: error.message, attempt }, 'Config server not ready') + } + + if (attempt < MAX_RETRIES) { + await new Promise(r => setTimeout(r, RETRY_DELAY)) + } + } + + electronLogger.warn('Bot config not available after retries') + return null + }) } function createWindow() { @@ -988,6 +1063,19 @@ app.on('activate', () => { } }) -app.on('before-quit', async () => { +// Track if we're currently cleaning up to avoid double-cleanup +let isQuitting = false + +app.on('before-quit', async (event) => { + if (isQuitting) return // Already cleaning up + + // Prevent immediate quit + event.preventDefault() + isQuitting = true + + // Stop backend processes await stopBackend() + + // Now actually quit + app.quit() }) diff --git a/apps/electron/package.json b/apps/electron/package.json index 41dc353..cab7ae2 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -52,9 +52,7 @@ { "from": "dist/bots", "to": "bots", - "filter": [ - "**/*" - ] + "filter": ["**/*"] }, { "from": "bots.yaml", diff --git a/apps/electron/scripts/compile-bots.ts b/apps/electron/scripts/compile-bots.ts index 7edbaee..ac14546 100644 --- a/apps/electron/scripts/compile-bots.ts +++ b/apps/electron/scripts/compile-bots.ts @@ -73,7 +73,7 @@ async function main() { // Compile each bot let successCount = 0 for (const bot of bots) { - const entry = bot.entry ?? 'src/index.ts' + const entry = bot.entry ?? 'src/app.ts' const sourcePath = path.resolve(ROOT_DIR, bot.source) const entryPath = path.join(sourcePath, entry) const outfile = path.join(OUTPUT_DIR, bot.name) diff --git a/apps/electron/src/preload.ts b/apps/electron/src/preload.ts index a380b83..8e2c578 100644 --- a/apps/electron/src/preload.ts +++ b/apps/electron/src/preload.ts @@ -43,4 +43,8 @@ contextBridge.exposeInMainWorld('electronAPI', { notifyLogsPanelState: (visible: boolean): void => { ipcRenderer.send('logs-panel:state-changed', visible) }, + + // Fetch bot config (proxied through main process to avoid CSP issues) + fetchBotConfig: (): Promise => + ipcRenderer.invoke('bot:fetchConfig'), }) diff --git a/apps/ui/src/components/DynamicSettings.svelte b/apps/ui/src/components/DynamicSettings.svelte index 291cfac..01381b2 100644 --- a/apps/ui/src/components/DynamicSettings.svelte +++ b/apps/ui/src/components/DynamicSettings.svelte @@ -39,13 +39,27 @@ let showSecrets: Record = $state({}) let collapsedGroups: Record = $state({}) - // Initialize form data once from initial values - let initialized = false + // Track previous initialValues to detect changes + let prevInitialValues: Record = {} + + // Initialize form data from initial values + // Re-sync if initialValues changes (e.g., async loading) $effect.pre(() => { - if (!initialized) { - formData = { ...initialValues } - initialized = true + for (const [key, value] of Object.entries(initialValues)) { + const prevValue = prevInitialValues[key] + const currentFormValue = formData[key] + + // Update formData if: + // 1. formData doesn't have this key yet, OR + // 2. initialValues changed AND formData still has the old initialValue (not user-modified) + if ( + currentFormValue === undefined || + (prevValue !== value && currentFormValue === prevValue) + ) { + formData[key] = value + } } + prevInitialValues = { ...initialValues } }) onMount(async () => { diff --git a/apps/ui/src/lib/backend-state.svelte.ts b/apps/ui/src/lib/backend-state.svelte.ts index 4de59c9..6384a27 100644 --- a/apps/ui/src/lib/backend-state.svelte.ts +++ b/apps/ui/src/lib/backend-state.svelte.ts @@ -195,8 +195,10 @@ function createBackendState() { const api = getElectronAPI() if (api) { // Electron mode: save via IPC + // Set backendReady to false BEFORE the IPC call to avoid race condition + // (backend:ready event might fire during the saveSettings call) + backendReady = false await api.saveSettings(newSettings) - backendReady = false // Will be set true when backend:ready event fires } else { // Web mode: save to localStorage saveSettingsToStorage(newSettings) diff --git a/apps/ui/src/lib/config-client.ts b/apps/ui/src/lib/config-client.ts index 2472f1c..16a1a1d 100644 --- a/apps/ui/src/lib/config-client.ts +++ b/apps/ui/src/lib/config-client.ts @@ -8,6 +8,8 @@ * - Group definitions for UI organization */ +import { getElectronAPI, isElectron } from './electron-api' + // Config server runs on bot port + 1 (default: 3001) export const DEFAULT_CONFIG_PORT = 3001 export const CONFIG_API_URL = `http://localhost:${DEFAULT_CONFIG_PORT}` @@ -69,17 +71,29 @@ export interface BotConfig { /** * Fetch bot configuration from the /config endpoint + * In Electron, uses IPC to avoid CSP issues with renderer fetch */ export async function fetchBotConfig(): Promise { + // In Electron, use IPC to fetch through main process (avoids CSP issues) + if (isElectron) { + try { + const api = getElectronAPI() + if (api) { + return (await api.fetchBotConfig()) as BotConfig | null + } + } catch { + return null + } + } + + // In web mode, fetch directly try { const response = await fetch(`${CONFIG_API_URL}/config`) if (!response.ok) { - console.error(`Failed to fetch config: HTTP ${response.status}`) return null } return await response.json() - } catch (error) { - console.error('Failed to fetch bot config:', error) + } catch { return null } } diff --git a/apps/ui/src/lib/electron-api.ts b/apps/ui/src/lib/electron-api.ts index 6de073b..67fd129 100644 --- a/apps/ui/src/lib/electron-api.ts +++ b/apps/ui/src/lib/electron-api.ts @@ -25,6 +25,9 @@ export interface ElectronAPI { // Logs panel menu communication onToggleLogsPanel: (callback: (visible: boolean) => void) => () => void notifyLogsPanelState: (visible: boolean) => void + + // Bot config (proxied through main process to avoid CSP issues) + fetchBotConfig: () => Promise } /** diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..e81da27 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +# Test configuration diff --git a/package.json b/package.json index 93b46ec..7ea8752 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev:electron": "bun run --filter @botarium/desktop dev:electron", "build": "bun run --filter '*' build", "package": "bun run --filter @botarium/desktop package", + "test": "bun test apps packages", "typecheck": "bun run --filter '*' typecheck", "lint": "eslint .", "format": "prettier --write .", diff --git a/packages/create-bot/src/scaffold.ts b/packages/create-bot/src/scaffold.ts index aee5786..c751ab6 100644 --- a/packages/create-bot/src/scaffold.ts +++ b/packages/create-bot/src/scaffold.ts @@ -110,6 +110,11 @@ async function copyDirectory( * Directories to skip based on context. */ function shouldSkipDirectory(dirName: string, ctx: TemplateContext): boolean { + // Skip AI directory when no AI provider selected + if (!ctx.isAi && dirName === 'ai') { + return true + } + // Skip DB-related directories when no database selected // memory and preferences depend on db if ( diff --git a/packages/create-bot/templates/slack-bot/bunfig.toml b/packages/create-bot/templates/slack-bot/bunfig.toml new file mode 100644 index 0000000..8755352 --- /dev/null +++ b/packages/create-bot/templates/slack-bot/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test/setup.ts"] diff --git a/packages/create-bot/templates/slack-bot/src/ai/stream.ts.tmpl b/packages/create-bot/templates/slack-bot/src/ai/stream.ts.tmpl new file mode 100644 index 0000000..28f5fa5 --- /dev/null +++ b/packages/create-bot/templates/slack-bot/src/ai/stream.ts.tmpl @@ -0,0 +1,84 @@ +/** + * AI Streaming utilities with error handling + */ +import { streamText } from 'ai' +import type { LanguageModelV1 } from 'ai' +import { slackLogger } from '../utils/logger' + +export interface StreamOptions { + model: LanguageModelV1 + system?: string + messages: Array<{ role: 'user' | 'assistant'; content: string }> +} + +/** + * Stream AI response with proper error handling. + * Validates API key if stream returns no content. + */ +export async function* streamAIResponse(options: StreamOptions): AsyncIterable { + const { model, system, messages } = options + + const result = streamText({ + model, + system, + messages, + }) + + // Collect chunks and handle errors + const chunks: string[] = [] + let streamError: Error | null = null + + try { + for await (const chunk of result.textStream) { + chunks.push(chunk) + } + } catch (error) { + streamError = error as Error + } + + // Check for errors even if stream appeared to succeed + if (chunks.length === 0 && !streamError) { + // Validate API key with a quick test call + try { + const testResponse = await fetch('https://api.openai.com/v1/models', { + headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` }, + signal: AbortSignal.timeout(5000), + }) + if (testResponse.status === 401) { + streamError = new Error('Invalid API key (401 Unauthorized)') + } else if (!testResponse.ok) { + streamError = new Error(`API error: ${testResponse.status}`) + } + } catch (error) { + streamError = error as Error + } + } + + // Handle any error with user-friendly messages + if (streamError) { + const err = streamError as Error & { status?: number; statusCode?: number } + const message = err.message || String(streamError) + const status = err.status || err.statusCode + + if (message.includes('API key') || message.includes('401') || message.includes('Unauthorized') || status === 401) { + slackLogger.error('Invalid OpenAI API key') + throw new Error('Invalid API key. Please check your OpenAI API key in Settings.') + } + if (message.includes('rate limit') || message.includes('429') || status === 429) { + slackLogger.error('OpenAI rate limit exceeded') + throw new Error('Rate limit exceeded. Please try again in a moment.') + } + if (message.includes('insufficient_quota') || message.includes('billing')) { + slackLogger.error('OpenAI billing/quota issue') + throw new Error('OpenAI quota exceeded. Please check your billing settings.') + } + + slackLogger.error({ error: message }, 'AI error') + throw streamError + } + + // Yield all collected chunks + for (const chunk of chunks) { + yield chunk + } +} diff --git a/packages/create-bot/templates/slack-bot/src/app.ts.tmpl b/packages/create-bot/templates/slack-bot/src/app.ts.tmpl index 9d49c78..b9ce628 100644 --- a/packages/create-bot/templates/slack-bot/src/app.ts.tmpl +++ b/packages/create-bot/templates/slack-bot/src/app.ts.tmpl @@ -9,6 +9,35 @@ import { appLogger, slackLogger } from './utils/logger' const SIMULATOR_BOT_TOKEN = 'xoxb-simulator-token' const SIMULATOR_APP_TOKEN = 'xapp-simulator-token' +/** + * Poll the emulator's health endpoint to verify a WebSocket connection exists. + * This ensures registration happens only after the WebSocket is tracked. + */ +async function waitForWebSocketConnection( + apiUrl: string, + timeoutMs: number = 5000 +): Promise { + const startTime = Date.now() + const baseUrl = apiUrl.replace(/\/api$/, '') + + while (Date.now() - startTime < timeoutMs) { + try { + const response = await fetch(`${baseUrl}/health`) + if (response.ok) { + const data = (await response.json()) as { connected_bots?: number } + // Health endpoint returns connected_bots which tracks WebSocket connections + if (data.connected_bots && data.connected_bots > 0) { + return true + } + } + } catch { + // Emulator not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + return false +} + function createApp() { const logLevel = settings.LOG_LEVEL === 'debug' ? LogLevel.DEBUG : LogLevel.INFO @@ -37,20 +66,32 @@ function createApp() { }) } -async function registerWithSimulator(maxRetries = 30, retryDelayMs = 2000) { +async function registerWithSimulator(maxRetries = 10, retryDelayMs = 1000) { const apiUrl = process.env.SLACK_API_URL if (!apiUrl) return for (let attempt = 1; attempt <= maxRetries; attempt++) { try { - const response = await fetch(`${apiUrl}/api/config/register`, { + // apiUrl already includes /api suffix (e.g., http://localhost:7557/api) + const response = await fetch(`${apiUrl}/config/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(slackConfig), }) + const data = (await response.json()) as { error?: string; message?: string } + if (!response.ok) { - throw new Error(`HTTP ${response.status}`) + // Handle WebSocket not ready - use faster retries for this transient condition + if (data.error === 'no_websocket_connection') { + slackLogger.debug( + { attempt, maxRetries }, + 'WebSocket not tracked yet, retrying quickly...' + ) + await new Promise((resolve) => setTimeout(resolve, 200)) + continue + } + throw new Error(`HTTP ${response.status}: ${data.message || data.error}`) } slackLogger.info('Registered with simulator') @@ -58,7 +99,7 @@ async function registerWithSimulator(maxRetries = 30, retryDelayMs = 2000) { } catch (error) { if (attempt < maxRetries) { slackLogger.debug( - { attempt, maxRetries }, + { attempt, maxRetries, error: String(error) }, 'Simulator not ready, retrying...' ) await new Promise((resolve) => setTimeout(resolve, retryDelayMs)) @@ -80,21 +121,44 @@ async function main() { const app = createApp() registerListeners(app) + + // Add WebSocket event logging for diagnostics (simulator mode only) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const receiver = (app as any).receiver as { client?: { on?: Function } } + + if (isSimulatorMode && receiver?.client?.on) { + receiver.client.on('connecting', () => + slackLogger.debug('WebSocket connecting...') + ) + receiver.client.on('connected', () => + slackLogger.info('WebSocket connected') + ) + receiver.client.on('disconnected', () => + slackLogger.warn('WebSocket disconnected') + ) + receiver.client.on('error', (err: Error) => + slackLogger.error({ err }, 'WebSocket error') + ) + } + await app.start() + slackLogger.info('Slack app started') - // Register with simulator after WebSocket is connected + // Register with simulator after verifying WebSocket is tracked if (isSimulatorMode) { - await registerWithSimulator() - - // Re-register on reconnection - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const receiver = (app as any).receiver as { client?: { on?: Function } } - receiver?.client?.on?.('connected', () => { - slackLogger.info('WebSocket reconnected, re-registering...') - registerWithSimulator().catch((err) => { - slackLogger.error({ err }, 'Failed to re-register with simulator') - }) - }) + const apiUrl = process.env.SLACK_API_URL + if (apiUrl) { + slackLogger.info('Waiting for WebSocket connection to be tracked...') + const connected = await waitForWebSocketConnection(apiUrl, 5000) + + if (!connected) { + slackLogger.warn( + 'WebSocket not detected after 5s, attempting registration anyway' + ) + } + + await registerWithSimulator() + } } // Graceful shutdown diff --git a/packages/create-bot/templates/slack-bot/src/config/http-server.ts.tmpl b/packages/create-bot/templates/slack-bot/src/config/http-server.ts.tmpl index 66ec940..76655bd 100644 --- a/packages/create-bot/templates/slack-bot/src/config/http-server.ts.tmpl +++ b/packages/create-bot/templates/slack-bot/src/config/http-server.ts.tmpl @@ -12,6 +12,7 @@ export interface ConfigResponse { schema: { settings: Record groups: GroupDefinition[] + model_tiers: Record> } values: Record } @@ -43,6 +44,7 @@ function getConfigResponse(): ConfigResponse { schema: { settings: settingsSchema, groups: config.groups, + model_tiers: {}, }, values, } @@ -56,6 +58,7 @@ export function startConfigServer(port: number) { try { const server = Bun.serve({ port, + hostname: '127.0.0.1', // Explicit IPv4 for Electron compatibility async fetch(req) { const url = new URL(req.url) diff --git a/packages/create-bot/templates/slack-bot/src/listeners/messages/index.ts b/packages/create-bot/templates/slack-bot/src/listeners/messages/index.ts index 863977b..4b9755c 100644 --- a/packages/create-bot/templates/slack-bot/src/listeners/messages/index.ts +++ b/packages/create-bot/templates/slack-bot/src/listeners/messages/index.ts @@ -59,6 +59,11 @@ async function processMessage( response += chunk } + // Ensure we have a response to send + if (!response.trim()) { + response = "I couldn't generate a response. Please try again." + } + // In DMs: reply inline unless user is in a thread if (isThreadReply) { await say({ text: response, thread_ts: threadTs }) @@ -66,7 +71,19 @@ async function processMessage( await say({ text: response }) } } catch (error) { + // Log the full error for debugging slackLogger.error({ error }, 'Error handling DM') - await say({ text: 'Sorry, something went wrong!' }) + + // Provide helpful error message based on error type + let errorMessage = 'Sorry, something went wrong!' + if (error instanceof Error) { + if (error.message.includes('API key') || error.message.includes('401') || error.message.includes('Unauthorized')) { + errorMessage = 'AI service authentication failed. Please check your API key in Settings.' + } else if (error.message.includes('rate limit') || error.message.includes('429')) { + errorMessage = 'AI service rate limit reached. Please try again in a moment.' + } + } + + await say({ text: errorMessage }) } } diff --git a/packages/create-bot/templates/slack-bot/src/response-handler.test.ts.tmpl b/packages/create-bot/templates/slack-bot/src/response-handler.test.ts.tmpl new file mode 100644 index 0000000..2584f7d --- /dev/null +++ b/packages/create-bot/templates/slack-bot/src/response-handler.test.ts.tmpl @@ -0,0 +1,30 @@ +import { describe, test, expect } from 'bun:test' +import { responseHandler } from './response-handler' + +describe('responseHandler', () => { + test('has systemPrompt defined', () => { + expect(responseHandler.systemPrompt).toBeDefined() + }) + + test('has suggestedPrompts defined', () => { + expect(responseHandler.suggestedPrompts).toBeDefined() + expect(Array.isArray(responseHandler.suggestedPrompts)).toBe(true) + }) + + test('ping command returns pong', async () => { + const context = { + channelId: 'test', + threadTs: '123', + userId: 'user', + teamId: 'team', + history: [], + } + + const chunks: string[] = [] + for await (const chunk of responseHandler.generateResponse('ping', context)) { + chunks.push(chunk) + } + + expect(chunks.join('')).toBe('pong') + }) +}) diff --git a/packages/create-bot/templates/slack-bot/src/response-handler.ts.tmpl b/packages/create-bot/templates/slack-bot/src/response-handler.ts.tmpl index 1fcdd49..48a5965 100644 --- a/packages/create-bot/templates/slack-bot/src/response-handler.ts.tmpl +++ b/packages/create-bot/templates/slack-bot/src/response-handler.ts.tmpl @@ -4,8 +4,8 @@ * This is the main file to customize bot responses. */ {{#if isAi}} -import { streamText } from 'ai' -import { getModel, settings } from './settings' +import { getModel } from './settings' +import { streamAIResponse } from './ai/stream' {{/if}} export interface ThreadContext { @@ -58,15 +58,11 @@ export const responseHandler: ResponseHandler = { messages.push({ role: 'user', content: message }) // Stream response from AI - const result = streamText({ + yield* streamAIResponse({ model: getModel(), system: this.systemPrompt, messages, }) - - for await (const chunk of result.textStream) { - yield chunk - } {{else}} // Echo response - replace with your logic diff --git a/packages/create-bot/templates/slack-bot/src/utils/logger.ts b/packages/create-bot/templates/slack-bot/src/utils/logger.ts index 815e122..2f42879 100644 --- a/packages/create-bot/templates/slack-bot/src/utils/logger.ts +++ b/packages/create-bot/templates/slack-bot/src/utils/logger.ts @@ -1,12 +1,13 @@ import pino from 'pino' -import { settings } from '../settings' +import { settings, isSimulatorMode } from '../settings' + +// Use JSON output in simulator mode (so Electron can parse logs) +// Use pretty output in local dev mode (when running standalone) +const usePretty = process.env.NODE_ENV !== 'production' && !isSimulatorMode && process.env.TERM_PROGRAM export const logger = pino({ level: settings.LOG_LEVEL, - transport: - process.env.NODE_ENV !== 'production' - ? { target: 'pino-pretty' } - : undefined, + transport: usePretty ? { target: 'pino-pretty' } : undefined, }) export type ModuleName = diff --git a/packages/create-bot/templates/slack-bot/test/setup.ts b/packages/create-bot/templates/slack-bot/test/setup.ts new file mode 100644 index 0000000..6301fe2 --- /dev/null +++ b/packages/create-bot/templates/slack-bot/test/setup.ts @@ -0,0 +1,2 @@ +// Test setup - set environment variables before any modules load +process.env.SLACK_API_URL = 'http://localhost:7557/api' diff --git a/packages/slack/src/server/web-api.ts b/packages/slack/src/server/web-api.ts index a93ff71..003af97 100644 --- a/packages/slack/src/server/web-api.ts +++ b/packages/slack/src/server/web-api.ts @@ -131,11 +131,14 @@ export class SlackWebAPI { const token = req.headers.get('Authorization')?.replace('Bearer ', '') // Accept any token starting with xoxb- or xoxp- + // Also whitelist internal simulator endpoints const isValidToken = token?.startsWith('xoxb-') || token?.startsWith('xoxp-') || !path.startsWith('/api/') || - path === '/api/apps.connections.open' + path === '/api/apps.connections.open' || + path === '/api/config/register' || + path === '/api/commands/register' if (!isValidToken && path.startsWith('/api/')) { return Response.json( @@ -247,7 +250,13 @@ export class SlackWebAPI { ): Promise { const { channel, text, thread_ts } = body + webApiLogger.debug({ body, channel, text }, 'chat.postMessage request') + if (!channel || !text) { + webApiLogger.error( + { channel, text, hasChannel: !!channel, hasText: !!text }, + 'chat.postMessage missing required argument' + ) return Response.json( { ok: false, error: 'missing_argument' }, { headers: corsHeaders() } From 28ee4a01163021050059a97fef1efa090f8cac88 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sat, 17 Jan 2026 18:57:52 +0000 Subject: [PATCH 03/90] Fix app_mention routing to target only mentioned bot - Add targetBotId parameter to dispatchAppMentionEvent and dispatchEvent - Route app_mention events only to the specifically mentioned bot - Fix Settings.svelte to pass botId to DynamicSettings - Change DynamicSettings to use reactive instead of onMount so it waits for botId before fetching config in Electron mode --- apps/ui/src/components/DynamicSettings.svelte | 157 +++++++++++------- apps/ui/src/components/Settings.svelte | 12 +- packages/slack/src/server/socket-mode.ts | 84 +++++++++- packages/slack/src/server/web-api.ts | 8 +- 4 files changed, 195 insertions(+), 66 deletions(-) diff --git a/apps/ui/src/components/DynamicSettings.svelte b/apps/ui/src/components/DynamicSettings.svelte index 01381b2..9b739f8 100644 --- a/apps/ui/src/components/DynamicSettings.svelte +++ b/apps/ui/src/components/DynamicSettings.svelte @@ -1,5 +1,4 @@