From d68acec58eb7a15b11c8be44db402864a5a49d28 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Sun, 1 Mar 2026 19:54:38 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20support=20Discord=20IM=20bo?= =?UTF-8?q?t=20intergration=20(#12517)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * clean fix tools calling results improve display support discord bot finish bot integration * improve next config * support queue callback mode * support queue callback mode * improve error * fix build * support serverless gateway * support serverless gateway * support serverless enable * improve ui * improve ui * add credentials config * improve and refactor data working * update config * fix integration * fix types * fix types * fix types * fix types * move files * fix update * fix update * fix update --- .agents/skills/chat-sdk/SKILL.md | 153 +++++ locales/en-US/agent.json | 34 ++ locales/en-US/chat.json | 1 + locales/zh-CN/agent.json | 34 ++ locales/zh-CN/chat.json | 1 + package.json | 5 + .../models/__tests__/agentBotProvider.test.ts | 527 ++++++++++++++++++ .../database/src/models/agentBotProvider.ts | 2 +- packages/types/src/topic/topic.ts | 8 +- scripts/serverLauncher/startServer.js | 40 ++ .../api/agent/gateway/discord/route.ts | 123 ++++ .../api/agent/gateway/start/route.ts | 31 ++ src/app/(backend)/api/agent/route.ts | 32 +- src/app/(backend)/api/agent/run/route.ts | 32 +- .../api/agent/webhooks/[platform]/route.ts | 26 + .../api/agent/webhooks/bot-callback/route.ts | 198 +++++++ src/app/(backend)/oidc/consent/route.ts | 1 - src/app/(backend)/oidc/handoff/route.ts | 2 +- src/libs/next/config/define-config.ts | 1 + src/libs/qstash/index.ts | 34 +- src/locales/default/agent.ts | 36 ++ src/locales/default/chat.ts | 1 + src/locales/default/index.ts | 2 + .../agent/_layout/Sidebar/Header/Nav.tsx | 22 +- .../agent/integration/PlatformDetail/Body.tsx | 309 ++++++++++ .../integration/PlatformDetail/Header.tsx | 98 ++++ .../integration/PlatformDetail/index.tsx | 196 +++++++ .../(main)/agent/integration/PlatformList.tsx | 130 +++++ src/routes/(main)/agent/integration/const.ts | 35 ++ src/routes/(main)/agent/integration/index.tsx | 72 +++ .../modules/AgentRuntime/RuntimeExecutors.ts | 33 +- .../__tests__/RuntimeExecutors.test.ts | 66 +++ src/server/routers/lambda/agentBotProvider.ts | 81 +++ src/server/routers/lambda/agentGroup.ts | 54 +- src/server/routers/lambda/index.ts | 2 + .../agentRuntime/AgentRuntimeService.test.ts | 191 +++++++ .../agentRuntime/AgentRuntimeService.ts | 312 +++++++++-- src/server/services/agentRuntime/types.ts | 71 ++- src/server/services/aiAgent/index.ts | 36 +- src/server/services/bot/AgentBridgeService.ts | 445 +++++++++++++++ src/server/services/bot/BotMessageRouter.ts | 297 ++++++++++ .../bot/__tests__/replyTemplate.test.ts | 423 ++++++++++++++ src/server/services/bot/ackPhrases/index.ts | 162 ++++++ .../services/bot/ackPhrases/vibeMatrix.ts | 415 ++++++++++++++ src/server/services/bot/discordRestApi.ts | 27 + src/server/services/bot/index.ts | 5 + src/server/services/bot/platforms/discord.ts | 110 ++++ src/server/services/bot/platforms/index.ts | 6 + src/server/services/bot/replyTemplate.ts | 269 +++++++++ src/server/services/bot/types.ts | 8 + src/server/services/gateway/GatewayManager.ts | 212 +++++++ .../services/gateway/botConnectQueue.ts | 87 +++ src/server/services/gateway/index.ts | 64 +++ src/services/agentBotProvider.ts | 39 ++ src/services/chatGroup/index.ts | 6 +- src/spa/router/desktopRouter.config.tsx | 7 + src/store/agent/slices/bot/action.ts | 81 +++ src/store/agent/slices/bot/index.ts | 1 + src/store/agent/store.ts | 5 + vercel.json | 6 + 60 files changed, 5530 insertions(+), 177 deletions(-) create mode 100644 .agents/skills/chat-sdk/SKILL.md create mode 100644 locales/en-US/agent.json create mode 100644 locales/zh-CN/agent.json create mode 100644 packages/database/src/models/__tests__/agentBotProvider.test.ts create mode 100644 src/app/(backend)/api/agent/gateway/discord/route.ts create mode 100644 src/app/(backend)/api/agent/gateway/start/route.ts create mode 100644 src/app/(backend)/api/agent/webhooks/[platform]/route.ts create mode 100644 src/app/(backend)/api/agent/webhooks/bot-callback/route.ts create mode 100644 src/locales/default/agent.ts create mode 100644 src/routes/(main)/agent/integration/PlatformDetail/Body.tsx create mode 100644 src/routes/(main)/agent/integration/PlatformDetail/Header.tsx create mode 100644 src/routes/(main)/agent/integration/PlatformDetail/index.tsx create mode 100644 src/routes/(main)/agent/integration/PlatformList.tsx create mode 100644 src/routes/(main)/agent/integration/const.ts create mode 100644 src/routes/(main)/agent/integration/index.tsx create mode 100644 src/server/routers/lambda/agentBotProvider.ts create mode 100644 src/server/services/bot/AgentBridgeService.ts create mode 100644 src/server/services/bot/BotMessageRouter.ts create mode 100644 src/server/services/bot/__tests__/replyTemplate.test.ts create mode 100644 src/server/services/bot/ackPhrases/index.ts create mode 100644 src/server/services/bot/ackPhrases/vibeMatrix.ts create mode 100644 src/server/services/bot/discordRestApi.ts create mode 100644 src/server/services/bot/index.ts create mode 100644 src/server/services/bot/platforms/discord.ts create mode 100644 src/server/services/bot/platforms/index.ts create mode 100644 src/server/services/bot/replyTemplate.ts create mode 100644 src/server/services/bot/types.ts create mode 100644 src/server/services/gateway/GatewayManager.ts create mode 100644 src/server/services/gateway/botConnectQueue.ts create mode 100644 src/server/services/gateway/index.ts create mode 100644 src/services/agentBotProvider.ts create mode 100644 src/store/agent/slices/bot/action.ts create mode 100644 src/store/agent/slices/bot/index.ts diff --git a/.agents/skills/chat-sdk/SKILL.md b/.agents/skills/chat-sdk/SKILL.md new file mode 100644 index 0000000000..ca315c920a --- /dev/null +++ b/.agents/skills/chat-sdk/SKILL.md @@ -0,0 +1,153 @@ +--- +name: chat-sdk +description: > + Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to + (1) Build a Slack, Teams, Google Chat, Discord, GitHub, or Linear bot, + (2) Use the Chat SDK to handle mentions, messages, reactions, slash commands, cards, modals, or streaming, + (3) Set up webhook handlers for chat platforms, + (4) Send interactive cards or stream AI responses to chat platforms. + Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "discord bot", "@chat-adapter", + building bots that work across multiple chat platforms. +--- + +# Chat SDK + +Unified TypeScript SDK for building chat bots across Slack, Teams, Google Chat, Discord, GitHub, and Linear. Write bot logic once, deploy everywhere. + +## Critical: Read the bundled docs + +The `chat` package ships with full documentation in `node_modules/chat/docs/` and TypeScript source types. **Always read these before writing code:** + +``` +node_modules/chat/docs/ # Full documentation (MDX files) +node_modules/chat/dist/ # Built types (.d.ts files) +``` + +Key docs to read based on task: + +- `docs/getting-started.mdx` — setup guides +- `docs/usage.mdx` — event handlers, threads, messages, channels +- `docs/streaming.mdx` — AI streaming with AI SDK +- `docs/cards.mdx` — JSX interactive cards +- `docs/actions.mdx` — button/dropdown handlers +- `docs/modals.mdx` — form dialogs (Slack only) +- `docs/adapters/*.mdx` — platform-specific adapter setup +- `docs/state/*.mdx` — state adapter config (Redis, ioredis, memory) + +Also read the TypeScript types from `node_modules/chat/dist/` to understand the full API surface. + +## Quick start + +```typescript +import { Chat } from 'chat'; +import { createSlackAdapter } from '@chat-adapter/slack'; +import { createRedisState } from '@chat-adapter/state-redis'; + +const bot = new Chat({ + userName: 'mybot', + adapters: { + slack: createSlackAdapter({ + botToken: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + }), + }, + state: createRedisState({ url: process.env.REDIS_URL! }), +}); + +bot.onNewMention(async (thread) => { + await thread.subscribe(); + await thread.post("Hello! I'm listening to this thread."); +}); + +bot.onSubscribedMessage(async (thread, message) => { + await thread.post(`You said: ${message.text}`); +}); +``` + +## Core concepts + +- **Chat** — main entry point, coordinates adapters and routes events +- **Adapters** — platform-specific (Slack, Teams, GChat, Discord, GitHub, Linear) +- **State** — pluggable persistence (Redis for prod, memory for dev) +- **Thread** — conversation thread with `post()`, `subscribe()`, `startTyping()` +- **Message** — normalized format with `text`, `formatted` (mdast AST), `raw` +- **Channel** — container for threads, supports listing and posting + +## Event handlers + +| Handler | Trigger | +| -------------------------- | ------------------------------------------------- | +| `onNewMention` | Bot @-mentioned in unsubscribed thread | +| `onSubscribedMessage` | Any message in subscribed thread | +| `onNewMessage(regex)` | Messages matching pattern in unsubscribed threads | +| `onSlashCommand("/cmd")` | Slash command invocations | +| `onReaction(emojis)` | Emoji reactions added/removed | +| `onAction(actionId)` | Button clicks and dropdown selections | +| `onAssistantThreadStarted` | Slack Assistants API thread opened | +| `onAppHomeOpened` | Slack App Home tab opened | + +## Streaming + +Pass any `AsyncIterable` to `thread.post()`. Works with AI SDK's `textStream`: + +```typescript +import { ToolLoopAgent } from 'ai'; +const agent = new ToolLoopAgent({ model: 'anthropic/claude-4.5-sonnet' }); + +bot.onNewMention(async (thread, message) => { + const result = await agent.stream({ prompt: message.text }); + await thread.post(result.textStream); +}); +``` + +## Cards (JSX) + +Set `jsxImportSource: "chat"` in tsconfig. Components: `Card`, `CardText`, `Button`, `Actions`, `Fields`, `Field`, `Select`, `SelectOption`, `Image`, `Divider`, `LinkButton`, `Section`, `RadioSelect`. + +```tsx +await thread.post( + + Your order has been received! + + + + + , +); +``` + +## Packages + +| Package | Purpose | +| ----------------------------- | ----------------------------- | +| `chat` | Core SDK | +| `@chat-adapter/slack` | Slack | +| `@chat-adapter/teams` | Microsoft Teams | +| `@chat-adapter/gchat` | Google Chat | +| `@chat-adapter/discord` | Discord | +| `@chat-adapter/github` | GitHub Issues | +| `@chat-adapter/linear` | Linear Issues | +| `@chat-adapter/state-redis` | Redis state (production) | +| `@chat-adapter/state-ioredis` | ioredis state (alternative) | +| `@chat-adapter/state-memory` | In-memory state (development) | + +## Changesets (Release Flow) + +This monorepo uses [Changesets](https://github.com/changesets/changesets) for versioning and changelogs. Every PR that changes a package's behavior must include a changeset. + +```bash +pnpm changeset +# → select affected package(s) (e.g. @chat-adapter/slack, chat) +# → choose bump type: patch (fixes), minor (features), major (breaking) +# → write a short summary for the CHANGELOG +``` + +This creates a file in `.changeset/` — commit it with the PR. When merged to `main`, the Changesets GitHub Action opens a "Version Packages" PR to bump versions and update CHANGELOGs. Merging that PR publishes to npm. + +## Webhook setup + +Each adapter exposes a webhook handler via `bot.webhooks.{platform}`. Wire these to your HTTP framework's routes (e.g. Next.js API routes, Hono, Express). diff --git a/locales/en-US/agent.json b/locales/en-US/agent.json new file mode 100644 index 0000000000..9cf1813acd --- /dev/null +++ b/locales/en-US/agent.json @@ -0,0 +1,34 @@ +{ + "integration.applicationId": "Application ID / Bot Username", + "integration.applicationIdPlaceholder": "e.g. 1234567890", + "integration.botToken": "Bot Token / API Key", + "integration.botTokenEncryptedHint": "Token will be encrypted and stored securely.", + "integration.botTokenHowToGet": "How to get?", + "integration.botTokenPlaceholderExisting": "Token is hidden for security", + "integration.botTokenPlaceholderNew": "Paste your bot token here", + "integration.connectionConfig": "Connection Configuration", + "integration.copied": "Copied to clipboard", + "integration.copy": "Copy", + "integration.deleteConfirm": "Are you sure you want to remove this integration?", + "integration.disabled": "Disabled", + "integration.discord.description": "Connect this assistant to Discord server for channel chat and direct messages.", + "integration.documentation": "Documentation", + "integration.enabled": "Enabled", + "integration.endpointUrl": "Interaction Endpoint URL", + "integration.endpointUrlHint": "Please copy this URL and paste it into the \"Interactions Endpoint URL\" field in the {{name}} Developer Portal.", + "integration.platforms": "Platforms", + "integration.publicKey": "Public Key", + "integration.publicKeyPlaceholder": "Required for interaction verification", + "integration.removeFailed": "Failed to remove integration", + "integration.removeIntegration": "Remove Integration", + "integration.removed": "Integration removed", + "integration.save": "Save Configuration", + "integration.saveFailed": "Failed to save configuration", + "integration.saveFirstWarning": "Please save configuration first", + "integration.saved": "Configuration saved successfully", + "integration.testConnection": "Test Connection", + "integration.testFailed": "Connection test failed", + "integration.testSuccess": "Connection test passed", + "integration.updateFailed": "Failed to update status", + "integration.validationError": "Please fill in Application ID and Token" +} diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index abde1fd245..75d6c05b67 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -336,6 +336,7 @@ "supervisor.todoList.allComplete": "All tasks completed", "supervisor.todoList.title": "Tasks Completed", "tab.groupProfile": "Group Profile", + "tab.integration": "Integration", "tab.profile": "Agent Profile", "tab.search": "Search", "task.activity.calling": "Calling Skill...", diff --git a/locales/zh-CN/agent.json b/locales/zh-CN/agent.json new file mode 100644 index 0000000000..440cd8f3bc --- /dev/null +++ b/locales/zh-CN/agent.json @@ -0,0 +1,34 @@ +{ + "integration.applicationId": "应用 ID / Bot 用户名", + "integration.applicationIdPlaceholder": "例如 1234567890", + "integration.botToken": "Bot Token / API Key", + "integration.botTokenEncryptedHint": "Token 将被加密安全存储。", + "integration.botTokenHowToGet": "如何获取?", + "integration.botTokenPlaceholderExisting": "出于安全考虑,Token 已隐藏", + "integration.botTokenPlaceholderNew": "在此粘贴你的 Bot Token", + "integration.connectionConfig": "连接配置", + "integration.copied": "已复制到剪贴板", + "integration.copy": "复制", + "integration.deleteConfirm": "确定要移除此集成吗?", + "integration.disabled": "已禁用", + "integration.discord.description": "将助手连接到 Discord 服务器,支持频道聊天和私信。", + "integration.documentation": "文档", + "integration.enabled": "已启用", + "integration.endpointUrl": "交互端点 URL", + "integration.endpointUrlHint": "请复制此 URL 并粘贴到 {{name}} 开发者门户的 \"Interactions Endpoint URL\" 字段中。", + "integration.platforms": "平台", + "integration.publicKey": "公钥", + "integration.publicKeyPlaceholder": "用于交互验证", + "integration.removeFailed": "移除集成失败", + "integration.removeIntegration": "移除集成", + "integration.removed": "集成已移除", + "integration.save": "保存配置", + "integration.saveFailed": "保存配置失败", + "integration.saveFirstWarning": "请先保存配置", + "integration.saved": "配置保存成功", + "integration.testConnection": "测试连接", + "integration.testFailed": "连接测试失败", + "integration.testSuccess": "连接测试通过", + "integration.updateFailed": "更新状态失败", + "integration.validationError": "请填写应用 ID 和 Token" +} diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index 65e97b5019..72077d3f0b 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -336,6 +336,7 @@ "supervisor.todoList.allComplete": "所有任务已完成", "supervisor.todoList.title": "任务完成", "tab.groupProfile": "群组档案", + "tab.integration": "集成", "tab.profile": "助理档案", "tab.search": "搜索", "task.activity.calling": "正在调用技能…", diff --git a/package.json b/package.json index bb5932dac6..957515e14e 100644 --- a/package.json +++ b/package.json @@ -164,7 +164,10 @@ "@better-auth/expo": "1.4.6", "@better-auth/passkey": "1.4.6", "@cfworker/json-schema": "^4.1.1", + "@chat-adapter/discord": "^4.14.0", + "@chat-adapter/state-ioredis": "^4.14.0", "@codesandbox/sandpack-react": "^2.20.0", + "@discordjs/rest": "^2.6.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", @@ -270,6 +273,7 @@ "better-auth-harmony": "^1.2.5", "better-call": "1.1.8", "brotli-wasm": "^3.0.1", + "chat": "^4.14.0", "chroma-js": "^3.2.0", "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", @@ -279,6 +283,7 @@ "debug": "^4.4.3", "dexie": "^3.2.7", "diff": "^8.0.3", + "discord-api-types": "^0.38.40", "drizzle-orm": "^0.45.1", "drizzle-zod": "^0.5.1", "epub2": "^3.0.2", diff --git a/packages/database/src/models/__tests__/agentBotProvider.test.ts b/packages/database/src/models/__tests__/agentBotProvider.test.ts new file mode 100644 index 0000000000..1b920ed78a --- /dev/null +++ b/packages/database/src/models/__tests__/agentBotProvider.test.ts @@ -0,0 +1,527 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getTestDB } from '../../core/getTestDB'; +import { agentBotProviders, agents, users } from '../../schemas'; +import type { LobeChatDatabase } from '../../type'; +import { AgentBotProviderModel } from '../agentBotProvider'; + +const serverDB: LobeChatDatabase = await getTestDB(); + +const userId = 'bot-provider-test-user-id'; +const userId2 = 'bot-provider-test-user-id-2'; +const agentId = 'bot-provider-test-agent-id'; +const agentId2 = 'bot-provider-test-agent-id-2'; + +const mockGateKeeper = { + decrypt: vi.fn(async (ciphertext: string) => ({ plaintext: ciphertext })), + encrypt: vi.fn(async (plaintext: string) => plaintext), +}; + +beforeEach(async () => { + await serverDB.delete(users); + await serverDB.insert(users).values([{ id: userId }, { id: userId2 }]); + await serverDB.insert(agents).values([ + { id: agentId, userId }, + { id: agentId2, userId: userId2 }, + ]); +}); + +afterEach(async () => { + await serverDB.delete(agentBotProviders); + await serverDB.delete(agents); + await serverDB.delete(users); + vi.clearAllMocks(); +}); + +describe('AgentBotProviderModel', () => { + describe('create', () => { + it('should create a bot provider without encryption', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + + const result = await model.create({ + agentId, + applicationId: 'app-123', + credentials: { botToken: 'token-abc', publicKey: 'pk-xyz' }, + platform: 'discord', + }); + + expect(result.id).toBeDefined(); + expect(result.agentId).toBe(agentId); + expect(result.platform).toBe('discord'); + expect(result.applicationId).toBe('app-123'); + expect(result.userId).toBe(userId); + expect(result.enabled).toBe(true); + }); + + it('should create a bot provider with gateKeeper encryption', async () => { + const model = new AgentBotProviderModel(serverDB, userId, mockGateKeeper); + + await model.create({ + agentId, + applicationId: 'app-456', + credentials: { botToken: 'token-def', signingSecret: 'secret-123' }, + platform: 'slack', + }); + + expect(mockGateKeeper.encrypt).toHaveBeenCalledWith( + JSON.stringify({ botToken: 'token-def', signingSecret: 'secret-123' }), + ); + }); + }); + + describe('delete', () => { + it('should delete a bot provider owned by current user', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + const created = await model.create({ + agentId, + applicationId: 'app-del', + credentials: { botToken: 'tok' }, + platform: 'discord', + }); + + await model.delete(created.id); + + const found = await model.findById(created.id); + expect(found).toBeUndefined(); + }); + + it('should not delete a bot provider owned by another user', async () => { + const model1 = new AgentBotProviderModel(serverDB, userId); + const model2 = new AgentBotProviderModel(serverDB, userId2); + + const created = await model1.create({ + agentId, + applicationId: 'app-iso', + credentials: { botToken: 'tok' }, + platform: 'discord', + }); + + await model2.delete(created.id); + + const found = await model1.findById(created.id); + expect(found).toBeDefined(); + }); + }); + + describe('query', () => { + it('should return only providers for the current user', async () => { + const model1 = new AgentBotProviderModel(serverDB, userId); + const model2 = new AgentBotProviderModel(serverDB, userId2); + + await model1.create({ + agentId, + applicationId: 'app-u1', + credentials: { botToken: 't1' }, + platform: 'discord', + }); + await model2.create({ + agentId: agentId2, + applicationId: 'app-u2', + credentials: { botToken: 't2' }, + platform: 'discord', + }); + + const results = await model1.query(); + expect(results).toHaveLength(1); + expect(results[0].userId).toBe(userId); + }); + + it('should filter by platform', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + await model.create({ + agentId, + applicationId: 'app-d', + credentials: { botToken: 't1' }, + platform: 'discord', + }); + await model.create({ + agentId, + applicationId: 'app-s', + credentials: { botToken: 't2' }, + platform: 'slack', + }); + + const results = await model.query({ platform: 'slack' }); + expect(results).toHaveLength(1); + expect(results[0].platform).toBe('slack'); + }); + + it('should filter by agentId', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + const otherAgentId = 'bot-provider-test-agent-other'; + await serverDB.insert(agents).values({ id: otherAgentId, userId }); + + await model.create({ + agentId, + applicationId: 'app-a1', + credentials: { botToken: 't1' }, + platform: 'discord', + }); + await model.create({ + agentId: otherAgentId, + applicationId: 'app-a2', + credentials: { botToken: 't2' }, + platform: 'discord', + }); + + const results = await model.query({ agentId }); + expect(results).toHaveLength(1); + expect(results[0].agentId).toBe(agentId); + }); + }); + + describe('findById', () => { + it('should return the provider with decrypted credentials', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + const created = await model.create({ + agentId, + applicationId: 'app-find', + credentials: { botToken: 'secret-token', publicKey: 'pk' }, + platform: 'discord', + }); + + const found = await model.findById(created.id); + expect(found).toBeDefined(); + expect(found!.credentials).toEqual({ botToken: 'secret-token', publicKey: 'pk' }); + }); + + it('should return undefined for non-existent id', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + const found = await model.findById('00000000-0000-0000-0000-000000000000'); + expect(found).toBeUndefined(); + }); + + it('should not return a provider owned by another user', async () => { + const model1 = new AgentBotProviderModel(serverDB, userId); + const model2 = new AgentBotProviderModel(serverDB, userId2); + const created = await model1.create({ + agentId, + applicationId: 'app-cross', + credentials: { botToken: 'tok' }, + platform: 'discord', + }); + + const found = await model2.findById(created.id); + expect(found).toBeUndefined(); + }); + }); + + describe('findByAgentId', () => { + it('should return all providers for an agent', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + await model.create({ + agentId, + applicationId: 'app-d2', + credentials: { botToken: 't1' }, + platform: 'discord', + }); + await model.create({ + agentId, + applicationId: 'app-s2', + credentials: { botToken: 't2' }, + platform: 'slack', + }); + + const results = await model.findByAgentId(agentId); + expect(results).toHaveLength(2); + }); + }); + + describe('update', () => { + it('should update non-credential fields', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + const created = await model.create({ + agentId, + applicationId: 'app-upd', + credentials: { botToken: 'tok' }, + platform: 'discord', + }); + + await model.update(created.id, { enabled: false }); + + const found = await model.findById(created.id); + expect(found!.enabled).toBe(false); + }); + + it('should update credentials with re-encryption', async () => { + const model = new AgentBotProviderModel(serverDB, userId, mockGateKeeper); + const created = await model.create({ + agentId, + applicationId: 'app-upd-cred', + credentials: { botToken: 'old-token' }, + platform: 'slack', + }); + + await model.update(created.id, { + credentials: { botToken: 'new-token', signingSecret: 'new-secret' }, + }); + + const found = await model.findById(created.id); + expect(found!.credentials).toEqual({ botToken: 'new-token', signingSecret: 'new-secret' }); + }); + + it('should not update a provider owned by another user', async () => { + const model1 = new AgentBotProviderModel(serverDB, userId); + const model2 = new AgentBotProviderModel(serverDB, userId2); + const created = await model1.create({ + agentId, + applicationId: 'app-upd-iso', + credentials: { botToken: 'tok' }, + platform: 'discord', + }); + + await model2.update(created.id, { enabled: false }); + + const found = await model1.findById(created.id); + expect(found!.enabled).toBe(true); + }); + }); + + describe('findEnabledByApplicationId', () => { + it('should return enabled provider matching platform and appId', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + await model.create({ + agentId, + applicationId: 'app-enabled', + credentials: { botToken: 'tok' }, + platform: 'discord', + }); + + const result = await model.findEnabledByApplicationId('discord', 'app-enabled'); + expect(result).not.toBeNull(); + expect(result!.applicationId).toBe('app-enabled'); + }); + + it('should return null for disabled provider', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + const created = await model.create({ + agentId, + applicationId: 'app-disabled', + credentials: { botToken: 'tok' }, + platform: 'discord', + }); + await model.update(created.id, { enabled: false }); + + const result = await model.findEnabledByApplicationId('discord', 'app-disabled'); + expect(result).toBeNull(); + }); + }); + + describe('findByPlatformAndAppId (static)', () => { + it('should find provider across all users', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + await model.create({ + agentId, + applicationId: 'global-app', + credentials: { botToken: 'tok' }, + platform: 'slack', + }); + + const result = await AgentBotProviderModel.findByPlatformAndAppId( + serverDB, + 'slack', + 'global-app', + ); + expect(result).toBeDefined(); + expect(result!.platform).toBe('slack'); + }); + + it('should return undefined for non-existent combination', async () => { + const result = await AgentBotProviderModel.findByPlatformAndAppId( + serverDB, + 'discord', + 'no-such-app', + ); + expect(result).toBeUndefined(); + }); + }); + + describe('findEnabledByPlatform (static)', () => { + it('should return Discord providers with botToken', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + await model.create({ + agentId, + applicationId: 'discord-app', + credentials: { botToken: 'discord-tok', publicKey: 'pk-abc' }, + platform: 'discord', + }); + + const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'discord'); + expect(results).toHaveLength(1); + expect(results[0].credentials.botToken).toBe('discord-tok'); + expect(results[0].credentials.publicKey).toBe('pk-abc'); + }); + + it('should return Slack providers with botToken (no publicKey required)', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + await model.create({ + agentId, + applicationId: 'slack-app', + credentials: { botToken: 'slack-tok', signingSecret: 'ss-123' }, + platform: 'slack', + }); + + const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'slack'); + expect(results).toHaveLength(1); + expect(results[0].credentials.botToken).toBe('slack-tok'); + expect(results[0].credentials.signingSecret).toBe('ss-123'); + }); + + it('should skip providers without botToken', async () => { + await serverDB.insert(agentBotProviders).values({ + agentId, + applicationId: 'no-token-app', + credentials: JSON.stringify({ publicKey: 'pk-only' }), + enabled: true, + platform: 'discord', + userId, + }); + + const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'discord'); + expect(results).toHaveLength(0); + }); + + it('should skip providers with null credentials', async () => { + await serverDB.insert(agentBotProviders).values({ + agentId, + applicationId: 'null-cred-app', + credentials: null, + enabled: true, + platform: 'discord', + userId, + }); + + const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'discord'); + expect(results).toHaveLength(0); + }); + + it('should skip disabled providers', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + const created = await model.create({ + agentId, + applicationId: 'disabled-plat', + credentials: { botToken: 'tok' }, + platform: 'discord', + }); + await model.update(created.id, { enabled: false }); + + const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'discord'); + expect(results).toHaveLength(0); + }); + + it('should skip providers with invalid JSON credentials', async () => { + await serverDB.insert(agentBotProviders).values({ + agentId, + applicationId: 'bad-json-app', + credentials: 'not-valid-json', + enabled: true, + platform: 'discord', + userId, + }); + + const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'discord'); + expect(results).toHaveLength(0); + }); + + it('should decrypt credentials with gateKeeper', async () => { + const encrypted = JSON.stringify({ botToken: 'encrypted-tok' }); + const gateKeeper = { + decrypt: vi.fn(async (ciphertext: string) => ({ plaintext: ciphertext })), + encrypt: vi.fn(), + }; + + await serverDB.insert(agentBotProviders).values({ + agentId, + applicationId: 'gk-app', + credentials: encrypted, + enabled: true, + platform: 'discord', + userId, + }); + + const results = await AgentBotProviderModel.findEnabledByPlatform( + serverDB, + 'discord', + gateKeeper, + ); + expect(gateKeeper.decrypt).toHaveBeenCalledWith(encrypted); + expect(results).toHaveLength(1); + }); + + it('should return providers from multiple users', async () => { + const model1 = new AgentBotProviderModel(serverDB, userId); + const model2 = new AgentBotProviderModel(serverDB, userId2); + + await model1.create({ + agentId, + applicationId: 'multi-app-1', + credentials: { botToken: 't1' }, + platform: 'slack', + }); + await model2.create({ + agentId: agentId2, + applicationId: 'multi-app-2', + credentials: { botToken: 't2' }, + platform: 'slack', + }); + + const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'slack'); + expect(results).toHaveLength(2); + }); + + it('should not return providers from a different platform', async () => { + const model = new AgentBotProviderModel(serverDB, userId); + await model.create({ + agentId, + applicationId: 'wrong-plat', + credentials: { botToken: 'tok' }, + platform: 'discord', + }); + + const results = await AgentBotProviderModel.findEnabledByPlatform(serverDB, 'slack'); + expect(results).toHaveLength(0); + }); + }); + + describe('decryptRow edge cases', () => { + it('should return empty credentials object when credentials is null', async () => { + await serverDB.insert(agentBotProviders).values({ + agentId, + applicationId: 'null-cred', + credentials: null, + enabled: true, + platform: 'discord', + userId, + }); + + const model = new AgentBotProviderModel(serverDB, userId); + const results = await model.query(); + expect(results).toHaveLength(1); + expect(results[0].credentials).toEqual({}); + }); + + it('should return empty credentials on decryption failure', async () => { + const failGateKeeper = { + decrypt: vi.fn(async () => { + throw new Error('decryption failed'); + }), + encrypt: vi.fn(async (plaintext: string) => plaintext), + }; + + await serverDB.insert(agentBotProviders).values({ + agentId, + applicationId: 'fail-decrypt', + credentials: 'encrypted-blob', + enabled: true, + platform: 'discord', + userId, + }); + + const model = new AgentBotProviderModel(serverDB, userId, failGateKeeper); + const results = await model.query(); + expect(results).toHaveLength(1); + expect(results[0].credentials).toEqual({}); + }); + }); +}); diff --git a/packages/database/src/models/agentBotProvider.ts b/packages/database/src/models/agentBotProvider.ts index 00696d4285..c9cd087869 100644 --- a/packages/database/src/models/agentBotProvider.ts +++ b/packages/database/src/models/agentBotProvider.ts @@ -172,7 +172,7 @@ export class AgentBotProviderModel { ? JSON.parse((await gateKeeper.decrypt(r.credentials)).plaintext) : JSON.parse(r.credentials); - if (!credentials.botToken || !credentials.publicKey) continue; + if (!credentials.botToken) continue; decrypted.push({ ...r, credentials }); } catch { diff --git a/packages/types/src/topic/topic.ts b/packages/types/src/topic/topic.ts index 5347dfb7c7..9198565c8a 100644 --- a/packages/types/src/topic/topic.ts +++ b/packages/types/src/topic/topic.ts @@ -11,7 +11,6 @@ export type TimeGroupId = | `${number}-${string}` | `${number}`; -/* eslint-disable typescript-sort-keys/string-enum */ export enum TopicDisplayMode { ByTime = 'byTime', Flat = 'flat', @@ -34,7 +33,14 @@ export interface TopicUserMemoryExtractRunState { traceId?: string; } +export interface ChatTopicBotContext { + applicationId: string; + platform: string; + platformThreadId: string; +} + export interface ChatTopicMetadata { + bot?: ChatTopicBotContext; /** * Cron job ID that triggered this topic creation (if created by scheduled task) */ diff --git a/scripts/serverLauncher/startServer.js b/scripts/serverLauncher/startServer.js index a7f16cc606..a360445111 100644 --- a/scripts/serverLauncher/startServer.js +++ b/scripts/serverLauncher/startServer.js @@ -126,6 +126,43 @@ const runScript = (scriptPath, useProxy = false) => { }); }; +// Function to start the bot gateway by calling the local API endpoint +const startGateway = async () => { + const KEY_VAULTS_SECRET = process.env.KEY_VAULTS_SECRET; + if (!KEY_VAULTS_SECRET) return; + + const port = process.env.PORT || 3210; + const url = `http://localhost:${port}/api/agent/gateway/start`; + const maxRetries = 10; + const retryDelay = 3000; + + for (let i = 0; i < maxRetries; i++) { + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${KEY_VAULTS_SECRET}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + if (res.ok) { + console.log('✅ Gateway: Started successfully.'); + return; + } + + console.warn(`⚠️ Gateway: Received status ${res.status}, retrying...`); + } catch { + if (i < maxRetries - 1) { + await new Promise((r) => setTimeout(r, retryDelay)); + } + } + } + + console.error('❌ Gateway: Failed to start after retries.'); +}; + // Main function to run the server with optional proxy const runServer = async () => { const PROXY_URL = process.env.PROXY_URL || ''; // Default empty string to avoid undefined errors @@ -164,6 +201,9 @@ const runServer = async () => { } } + // Start gateway in background after server is ready + startGateway(); + // Run the server in either database or non-database mode await runServer(); })(); diff --git a/src/app/(backend)/api/agent/gateway/discord/route.ts b/src/app/(backend)/api/agent/gateway/discord/route.ts new file mode 100644 index 0000000000..3283ea01fc --- /dev/null +++ b/src/app/(backend)/api/agent/gateway/discord/route.ts @@ -0,0 +1,123 @@ +import debug from 'debug'; +import type { NextRequest } from 'next/server'; +import { after } from 'next/server'; + +import { getServerDB } from '@/database/core/db-adaptor'; +import { AgentBotProviderModel } from '@/database/models/agentBotProvider'; +import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt'; +import { Discord, type DiscordBotConfig } from '@/server/services/bot/platforms/discord'; +import { BotConnectQueue } from '@/server/services/gateway/botConnectQueue'; + +const log = debug('lobe-server:bot:gateway:cron:discord'); + +const GATEWAY_DURATION_MS = 600_000; // 10 minutes +const POLL_INTERVAL_MS = 30_000; // 30 seconds + +export const maxDuration = 800; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function processConnectQueue(remainingMs: number): Promise { + const queue = new BotConnectQueue(); + const items = await queue.popAll(); + const discordItems = items.filter((item) => item.platform === 'discord'); + + if (discordItems.length === 0) return 0; + + log('Processing %d queued discord connect requests', discordItems.length); + + const serverDB = await getServerDB(); + const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); + let processed = 0; + + for (const item of discordItems) { + try { + const model = new AgentBotProviderModel(serverDB, item.userId, gateKeeper); + const provider = await model.findEnabledByApplicationId('discord', item.applicationId); + + if (!provider) { + log('No enabled provider found for queued appId=%s', item.applicationId); + await queue.remove('discord', item.applicationId); + continue; + } + + const bot = new Discord({ + ...provider.credentials, + applicationId: provider.applicationId, + } as DiscordBotConfig); + + await bot.start({ + durationMs: remainingMs, + waitUntil: (task) => { + after(() => task); + }, + }); + + processed++; + log('Started queued bot appId=%s', item.applicationId); + } catch (err) { + log('Failed to start queued bot appId=%s: %O', item.applicationId, err); + } + + await queue.remove('discord', item.applicationId); + } + + return processed; +} + +export async function GET(request: NextRequest) { + const authHeader = request.headers.get('authorization'); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return new Response('Unauthorized', { status: 401 }); + } + + const serverDB = await getServerDB(); + const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); + const providers = await AgentBotProviderModel.findEnabledByPlatform( + serverDB, + 'discord', + gateKeeper, + ); + + log('Found %d enabled Discord providers', providers.length); + + let started = 0; + + for (const provider of providers) { + const { applicationId, credentials } = provider; + + try { + const bot = new Discord({ ...credentials, applicationId } as DiscordBotConfig); + + await bot.start({ + durationMs: GATEWAY_DURATION_MS, + waitUntil: (task) => { + after(() => task); + }, + }); + + started++; + log('Started gateway listener for appId=%s', applicationId); + } catch (err) { + log('Failed to start gateway listener for appId=%s: %O', applicationId, err); + } + } + + // Process any queued connect requests immediately + const queued = await processConnectQueue(GATEWAY_DURATION_MS); + + // Poll for new connect requests in background + after(async () => { + const pollEnd = Date.now() + GATEWAY_DURATION_MS; + + while (Date.now() < pollEnd) { + await sleep(POLL_INTERVAL_MS); + if (Date.now() >= pollEnd) break; + + const remaining = pollEnd - Date.now(); + await processConnectQueue(remaining); + } + }); + + return Response.json({ queued, started, total: providers.length }); +} diff --git a/src/app/(backend)/api/agent/gateway/start/route.ts b/src/app/(backend)/api/agent/gateway/start/route.ts new file mode 100644 index 0000000000..a0d0c3d01c --- /dev/null +++ b/src/app/(backend)/api/agent/gateway/start/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; + +import { getServerDBConfig } from '@/config/db'; +import { GatewayService } from '@/server/services/gateway'; + +export const POST = async (req: Request): Promise => { + const { KEY_VAULTS_SECRET } = getServerDBConfig(); + + const authHeader = req.headers.get('authorization'); + if (authHeader !== `Bearer ${KEY_VAULTS_SECRET}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json().catch(() => ({})); + const service = new GatewayService(); + + try { + if (body.restart) { + console.info('[GatewayService] Restarting...'); + await service.stop(); + } + + await service.ensureRunning(); + console.info('[GatewayService] Started successfully'); + + return NextResponse.json({ status: body.restart ? 'restarted' : 'started' }); + } catch (error) { + console.error('[GatewayService] Failed to start:', error); + return NextResponse.json({ error: 'Failed to start gateway' }, { status: 500 }); + } +}; diff --git a/src/app/(backend)/api/agent/route.ts b/src/app/(backend)/api/agent/route.ts index 5d9341481a..9ddeb6850b 100644 --- a/src/app/(backend)/api/agent/route.ts +++ b/src/app/(backend)/api/agent/route.ts @@ -3,41 +3,11 @@ import { type NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { getServerDB } from '@/database/core/db-adaptor'; +import { verifyQStashSignature } from '@/libs/qstash'; import { AiAgentService } from '@/server/services/aiAgent'; const log = debug('api-route:agent:exec'); -/** - * Verify QStash signature using Receiver - * Returns true if verification is disabled or signature is valid - */ -const verifyQStashSignature = async (request: NextRequest, rawBody: string): Promise => { - const currentSigningKey = process.env.QSTASH_CURRENT_SIGNING_KEY; - const nextSigningKey = process.env.QSTASH_NEXT_SIGNING_KEY; - - // If no signing keys configured, skip verification - if (!currentSigningKey || !nextSigningKey) { - log('QStash signature verification disabled (no signing keys configured)'); - return false; - } - - const signature = request.headers.get('Upstash-Signature'); - if (!signature) { - log('Missing Upstash-Signature header'); - return false; - } - - const { Receiver } = await import('@upstash/qstash'); - const receiver = new Receiver({ currentSigningKey, nextSigningKey }); - - try { - return await receiver.verify({ body: rawBody, signature }); - } catch (error) { - log('QStash signature verification failed: %O', error); - return false; - } -}; - /** * Verify API key from Authorization header * Format: Bearer diff --git a/src/app/(backend)/api/agent/run/route.ts b/src/app/(backend)/api/agent/run/route.ts index 6d572e3549..756b2887a6 100644 --- a/src/app/(backend)/api/agent/run/route.ts +++ b/src/app/(backend)/api/agent/run/route.ts @@ -3,42 +3,12 @@ import { type NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { getServerDB } from '@/database/core/db-adaptor'; +import { verifyQStashSignature } from '@/libs/qstash'; import { AgentRuntimeCoordinator } from '@/server/modules/AgentRuntime'; import { AgentRuntimeService } from '@/server/services/agentRuntime'; const log = debug('api-route:agent:execute-step'); -/** - * Verify QStash signature using Receiver - * Returns true if verification is disabled or signature is valid - */ -async function verifyQStashSignature(request: NextRequest, rawBody: string): Promise { - const currentSigningKey = process.env.QSTASH_CURRENT_SIGNING_KEY; - const nextSigningKey = process.env.QSTASH_NEXT_SIGNING_KEY; - - // If no signing keys configured, skip verification - if (!currentSigningKey || !nextSigningKey) { - log('QStash signature verification disabled (no signing keys configured)'); - return false; - } - - const signature = request.headers.get('Upstash-Signature'); - if (!signature) { - log('Missing Upstash-Signature header'); - return false; - } - - const { Receiver } = await import('@upstash/qstash'); - const receiver = new Receiver({ currentSigningKey, nextSigningKey }); - - try { - return await receiver.verify({ body: rawBody, signature }); - } catch (error) { - log('QStash signature verification failed: %O', error); - return false; - } -} - export async function POST(request: NextRequest) { const startTime = Date.now(); diff --git a/src/app/(backend)/api/agent/webhooks/[platform]/route.ts b/src/app/(backend)/api/agent/webhooks/[platform]/route.ts new file mode 100644 index 0000000000..90324f2c45 --- /dev/null +++ b/src/app/(backend)/api/agent/webhooks/[platform]/route.ts @@ -0,0 +1,26 @@ +import debug from 'debug'; + +import { getBotMessageRouter } from '@/server/services/bot'; + +const log = debug('lobe-server:bot:webhook-route'); + +/** + * Unified webhook endpoint for Chat SDK bot platforms (Discord, Slack, etc.). + * + * Each platform adapter handles its own signature verification and event parsing. + * The BotMessageRouter routes the request to the correct Chat SDK bot instance. + * + * Route: POST /api/agent/webhooks/[platform] + */ +export const POST = async ( + req: Request, + { params }: { params: Promise<{ platform: string }> }, +): Promise => { + const { platform } = await params; + + log('Received webhook: platform=%s, url=%s', platform, req.url); + + const router = getBotMessageRouter(); + const handler = router.getWebhookHandler(platform); + return handler(req); +}; diff --git a/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts b/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts new file mode 100644 index 0000000000..8397311898 --- /dev/null +++ b/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts @@ -0,0 +1,198 @@ +import debug from 'debug'; +import { NextResponse } from 'next/server'; + +import { getServerDB } from '@/database/core/db-adaptor'; +import { AgentBotProviderModel } from '@/database/models/agentBotProvider'; +import { verifyQStashSignature } from '@/libs/qstash'; +import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt'; +import { DiscordRestApi } from '@/server/services/bot/discordRestApi'; +import { + renderError, + renderFinalReply, + renderStepProgress, + splitMessage, +} from '@/server/services/bot/replyTemplate'; + +const log = debug('api-route:agent:bot-callback'); + +/** + * Parse a Chat SDK platformThreadId (e.g. "discord:guildId:channelId[:threadId]") + * and return the actual Discord channel ID to send messages to. + */ +function extractDiscordChannelId(platformThreadId: string): string { + const parts = platformThreadId.split(':'); + // parts[0]='discord', parts[1]=guildId, parts[2]=channelId, parts[3]=threadId (optional) + // When there's a Discord thread, use threadId; otherwise use channelId + return parts[3] || parts[2]; +} + +/** + * Bot callback endpoint for agent step/completion webhooks. + * + * In queue mode, AgentRuntimeService fires webhooks (via QStash) after each step + * and on completion. This endpoint processes those callbacks and updates + * Discord messages via REST API. + * + * Route: POST /api/agent/webhooks/bot-callback + */ +export async function POST(request: Request): Promise { + const rawBody = await request.text(); + + const isValid = await verifyQStashSignature(request, rawBody); + if (!isValid) { + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); + } + + const body = JSON.parse(rawBody); + + const { type, applicationId, platformThreadId, progressMessageId } = body; + + log( + 'bot-callback: parsed body keys=%s, type=%s, applicationId=%s, platformThreadId=%s, progressMessageId=%s', + Object.keys(body).join(','), + type, + applicationId, + platformThreadId, + progressMessageId, + ); + + if (!type || !applicationId || !platformThreadId || !progressMessageId) { + return NextResponse.json( + { + error: 'Missing required fields: type, applicationId, platformThreadId, progressMessageId', + }, + { status: 400 }, + ); + } + + log('bot-callback: type=%s, appId=%s, thread=%s', type, applicationId, platformThreadId); + + try { + // Look up bot token from DB + const serverDB = await getServerDB(); + const row = await AgentBotProviderModel.findByPlatformAndAppId( + serverDB, + 'discord', + applicationId, + ); + + if (!row?.credentials) { + log('bot-callback: no bot provider found for appId=%s', applicationId); + return NextResponse.json({ error: 'Bot provider not found' }, { status: 404 }); + } + + // Decrypt credentials + const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); + let credentials: Record; + try { + credentials = JSON.parse((await gateKeeper.decrypt(row.credentials)).plaintext); + } catch { + credentials = JSON.parse(row.credentials); + } + + const botToken = credentials.botToken; + if (!botToken) { + log('bot-callback: no botToken in credentials for appId=%s', applicationId); + return NextResponse.json({ error: 'Bot token not found' }, { status: 500 }); + } + + const discord = new DiscordRestApi(botToken); + const channelId = extractDiscordChannelId(platformThreadId); + + if (type === 'step') { + await handleStepCallback(body, discord, channelId, progressMessageId); + } else if (type === 'completion') { + await handleCompletionCallback(body, discord, channelId, progressMessageId); + } else { + return NextResponse.json({ error: `Unknown callback type: ${type}` }, { status: 400 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('bot-callback error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal error' }, + { status: 500 }, + ); + } +} + +async function handleStepCallback( + body: Record, + discord: DiscordRestApi, + channelId: string, + progressMessageId: string, +): Promise { + const { shouldContinue } = body; + if (!shouldContinue) return; + + const progressText = renderStepProgress({ + content: body.content, + elapsedMs: body.elapsedMs, + executionTimeMs: body.executionTimeMs ?? 0, + lastContent: body.lastLLMContent, + lastToolsCalling: body.lastToolsCalling, + reasoning: body.reasoning, + stepType: body.stepType ?? 'call_llm', + thinking: body.thinking ?? false, + toolsCalling: body.toolsCalling, + toolsResult: body.toolsResult, + totalCost: body.totalCost ?? 0, + totalInputTokens: body.totalInputTokens ?? 0, + totalOutputTokens: body.totalOutputTokens ?? 0, + totalSteps: body.totalSteps ?? 0, + totalTokens: body.totalTokens ?? 0, + totalToolCalls: body.totalToolCalls, + }); + + try { + await discord.editMessage(channelId, progressMessageId, progressText); + } catch (error) { + log('handleStepCallback: failed to edit progress message: %O', error); + } +} + +async function handleCompletionCallback( + body: Record, + discord: DiscordRestApi, + channelId: string, + progressMessageId: string, +): Promise { + const { reason, lastAssistantContent, errorMessage } = body; + + if (reason === 'error') { + const errorText = renderError(errorMessage || 'Agent execution failed'); + try { + await discord.editMessage(channelId, progressMessageId, errorText); + } catch (error) { + log('handleCompletionCallback: failed to edit error message: %O', error); + } + return; + } + + if (!lastAssistantContent) { + log('handleCompletionCallback: no lastAssistantContent, skipping'); + return; + } + + const finalText = renderFinalReply(lastAssistantContent, { + elapsedMs: body.duration, + llmCalls: body.llmCalls ?? 0, + toolCalls: body.toolCalls ?? 0, + totalCost: body.cost ?? 0, + totalTokens: body.totalTokens ?? 0, + }); + + const chunks = splitMessage(finalText); + + try { + await discord.editMessage(channelId, progressMessageId, chunks[0]); + + // Post overflow chunks as follow-up messages + for (let i = 1; i < chunks.length; i++) { + await discord.createMessage(channelId, chunks[i]); + } + } catch (error) { + log('handleCompletionCallback: failed to edit/post final message: %O', error); + } +} diff --git a/src/app/(backend)/oidc/consent/route.ts b/src/app/(backend)/oidc/consent/route.ts index fdbc46385f..f852f76b98 100644 --- a/src/app/(backend)/oidc/consent/route.ts +++ b/src/app/(backend)/oidc/consent/route.ts @@ -135,7 +135,6 @@ export async function POST(request: NextRequest) { status: 303, }); } catch (error) { - log('Error processing consent: %s', error instanceof Error ? error.message : 'unknown error'); console.error('Error processing consent:', error); return NextResponse.json( { diff --git a/src/app/(backend)/oidc/handoff/route.ts b/src/app/(backend)/oidc/handoff/route.ts index 2955e1fe1e..7e05f74d73 100644 --- a/src/app/(backend)/oidc/handoff/route.ts +++ b/src/app/(backend)/oidc/handoff/route.ts @@ -40,7 +40,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ data: result, success: true }); } catch (error) { - log('Error fetching handoff record: %O', error); + console.error('Error fetching handoff record: %O', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/src/libs/next/config/define-config.ts b/src/libs/next/config/define-config.ts index 02c0169d35..b5c3f55b9f 100644 --- a/src/libs/next/config/define-config.ts +++ b/src/libs/next/config/define-config.ts @@ -355,6 +355,7 @@ export function defineConfig(config: CustomNextConfig) { serverExternalPackages: config.serverExternalPackages ?? [ 'pdfkit', '@napi-rs/canvas', + 'discord.js', 'pdfjs-dist', 'ajv', 'oidc-provider', diff --git a/src/libs/qstash/index.ts b/src/libs/qstash/index.ts index d955938266..7622bcb69b 100644 --- a/src/libs/qstash/index.ts +++ b/src/libs/qstash/index.ts @@ -1,5 +1,8 @@ -import { Client } from '@upstash/qstash'; +import { Client, Receiver } from '@upstash/qstash'; import { Client as WorkflowClient } from '@upstash/workflow'; +import debug from 'debug'; + +const log = debug('lobe-server:qstash'); const headers = { ...(process.env.VERCEL_AUTOMATION_BYPASS_SECRET && { @@ -26,3 +29,32 @@ export const workflowClient = new WorkflowClient({ headers, token: process.env.QSTASH_TOKEN!, }); + +/** + * Verify QStash signature using Receiver. + * Returns true if signing keys are not configured (verification skipped) or signature is valid. + */ +export async function verifyQStashSignature(request: Request, rawBody: string): Promise { + const currentSigningKey = process.env.QSTASH_CURRENT_SIGNING_KEY; + const nextSigningKey = process.env.QSTASH_NEXT_SIGNING_KEY; + + if (!currentSigningKey || !nextSigningKey) { + log('QStash signature verification disabled (no signing keys configured)'); + return false; + } + + const signature = request.headers.get('Upstash-Signature'); + if (!signature) { + log('Missing Upstash-Signature header'); + return false; + } + + const receiver = new Receiver({ currentSigningKey, nextSigningKey }); + + try { + return await receiver.verify({ body: rawBody, signature }); + } catch (error) { + log('QStash signature verification failed: %O', error); + return false; + } +} diff --git a/src/locales/default/agent.ts b/src/locales/default/agent.ts new file mode 100644 index 0000000000..b55685f80c --- /dev/null +++ b/src/locales/default/agent.ts @@ -0,0 +1,36 @@ +export default { + 'integration.applicationId': 'Application ID / Bot Username', + 'integration.applicationIdPlaceholder': 'e.g. 1234567890', + 'integration.botToken': 'Bot Token / API Key', + 'integration.botTokenEncryptedHint': 'Token will be encrypted and stored securely.', + 'integration.botTokenHowToGet': 'How to get?', + 'integration.botTokenPlaceholderExisting': 'Token is hidden for security', + 'integration.botTokenPlaceholderNew': 'Paste your bot token here', + 'integration.connectionConfig': 'Connection Configuration', + 'integration.copied': 'Copied to clipboard', + 'integration.copy': 'Copy', + 'integration.deleteConfirm': 'Are you sure you want to remove this integration?', + 'integration.disabled': 'Disabled', + 'integration.discord.description': + 'Connect this assistant to Discord server for channel chat and direct messages.', + 'integration.documentation': 'Documentation', + 'integration.enabled': 'Enabled', + 'integration.endpointUrl': 'Interaction Endpoint URL', + 'integration.endpointUrlHint': + 'Please copy this URL and paste it into the "Interactions Endpoint URL" field in the {{name}} Developer Portal.', + 'integration.platforms': 'Platforms', + 'integration.publicKey': 'Public Key', + 'integration.publicKeyPlaceholder': 'Required for interaction verification', + 'integration.removeIntegration': 'Remove Integration', + 'integration.removed': 'Integration removed', + 'integration.removeFailed': 'Failed to remove integration', + 'integration.save': 'Save Configuration', + 'integration.saveFailed': 'Failed to save configuration', + 'integration.saveFirstWarning': 'Please save configuration first', + 'integration.saved': 'Configuration saved successfully', + 'integration.testConnection': 'Test Connection', + 'integration.testFailed': 'Connection test failed', + 'integration.testSuccess': 'Connection test passed', + 'integration.updateFailed': 'Failed to update status', + 'integration.validationError': 'Please fill in Application ID and Token', +} as const; diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts index d93d2b2e76..e460b772a9 100644 --- a/src/locales/default/chat.ts +++ b/src/locales/default/chat.ts @@ -370,6 +370,7 @@ export default { 'supervisor.todoList.allComplete': 'All tasks completed', 'supervisor.todoList.title': 'Tasks Completed', 'tab.groupProfile': 'Group Profile', + 'tab.integration': 'Integration', 'tab.profile': 'Agent Profile', 'tab.search': 'Search', 'task.activity.calling': 'Calling Skill...', diff --git a/src/locales/default/index.ts b/src/locales/default/index.ts index acd1ff2860..d5b717a854 100644 --- a/src/locales/default/index.ts +++ b/src/locales/default/index.ts @@ -1,3 +1,4 @@ +import agent from './agent'; import agentGroup from './agentGroup'; import auth from './auth'; import authError from './authError'; @@ -42,6 +43,7 @@ import video from './video'; import welcome from './welcome'; const resources = { + agent, agentGroup, auth, authError, diff --git a/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx b/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx index 2c867c18c1..cee0604261 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx @@ -2,7 +2,7 @@ import { Flexbox } from '@lobehub/ui'; import { BotPromptIcon } from '@lobehub/ui/icons'; -import { MessageSquarePlusIcon, SearchIcon } from 'lucide-react'; +import { BlocksIcon, MessageSquarePlusIcon, SearchIcon } from 'lucide-react'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; @@ -12,24 +12,25 @@ import NavItem from '@/features/NavPanel/components/NavItem'; import { useQueryRoute } from '@/hooks/useQueryRoute'; import { usePathname } from '@/libs/router/navigation'; import { useActionSWR } from '@/libs/swr'; -import { useAgentStore } from '@/store/agent'; -import { builtinAgentSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; import { useGlobalStore } from '@/store/global'; import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig'; +import { useUserStore } from '@/store/user'; +import { userGeneralSettingsSelectors } from '@/store/user/selectors'; const Nav = memo(() => { const { t } = useTranslation('chat'); const { t: tTopic } = useTranslation('topic'); - const isInbox = useAgentStore(builtinAgentSelectors.isInboxAgent); const params = useParams(); const agentId = params.aid; const pathname = usePathname(); const isProfileActive = pathname.includes('/profile'); + const isIntegrationActive = pathname.includes('/integration'); const router = useQueryRoute(); const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors); const toggleCommandMenu = useGlobalStore((s) => s.toggleCommandMenu); - const hideProfile = isInbox || !isAgentEditable; + const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode); + const hideProfile = !isAgentEditable; const switchTopic = useChatStore((s) => s.switchTopic); const [openNewTopicOrSaveTopic] = useChatStore((s) => [s.openNewTopicOrSaveTopic]); @@ -60,6 +61,17 @@ const Nav = memo(() => { }} /> )} + {!hideProfile && isDevMode && ( + { + switchTopic(null, { skipRefreshMessage: true }); + router.push(urlJoin('/agent', agentId!, 'integration')); + }} + /> + )} ({ + actionBar: css` + display: flex; + align-items: center; + justify-content: space-between; + padding-block-start: 32px; + `, + content: css` + display: flex; + flex-direction: column; + gap: 24px; + + width: 100%; + max-width: 800px; + margin-block: 0; + margin-inline: auto; + padding: 24px; + `, + field: css` + display: flex; + flex-direction: column; + gap: 8px; + `, + helperLink: css` + cursor: pointer; + + display: flex; + gap: 4px; + align-items: center; + + font-size: 12px; + color: ${cssVar.colorPrimary}; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + `, + label: css` + display: flex; + align-items: center; + justify-content: space-between; + + font-size: 14px; + font-weight: 600; + color: ${cssVar.colorText}; + `, + labelLeft: css` + display: flex; + gap: 8px; + align-items: center; + `, + section: css` + display: flex; + flex-direction: column; + gap: 24px; + `, + sectionTitle: css` + display: flex; + gap: 8px; + align-items: center; + + font-size: 12px; + font-weight: 700; + color: ${cssVar.colorTextQuaternary}; + + &::before { + content: ''; + + display: block; + + width: 6px; + height: 6px; + border-radius: 50%; + + background: ${cssVar.colorPrimary}; + } + `, + webhookBox: css` + overflow: hidden; + flex: 1; + + height: ${cssVar.controlHeight}; + padding-inline: 12px; + border: 1px solid ${cssVar.colorBorder}; + border-radius: ${cssVar.borderRadius}; + + font-family: monospace; + font-size: 13px; + line-height: ${cssVar.controlHeight}; + color: ${cssVar.colorTextSecondary}; + text-overflow: ellipsis; + white-space: nowrap; + + background: ${cssVar.colorFillQuaternary}; + `, +})); + +interface BodyProps { + form: FormInstance; + hasConfig: boolean; + onCopied: () => void; + onDelete: () => void; + onSave: () => void; + onTestConnection: () => void; + provider: IntegrationProvider; + saveResult?: TestResult; + saving: boolean; + testing: boolean; + testResult?: TestResult; +} + +const Body = memo( + ({ + provider, + form, + hasConfig, + saveResult, + saving, + testing, + testResult, + onSave, + onDelete, + onTestConnection, + onCopied, + }) => { + const { t } = useTranslation('agent'); + const origin = useAppOrigin(); + + return ( +
+
+ {/* Connection Config */} +
+
{t('integration.connectionConfig')}
+ +
+
+
+ {t('integration.applicationId')} + {provider.fieldTags.appId && {provider.fieldTags.appId}} +
+
+ + + +
+ +
+
+
+ {t('integration.botToken')} + {provider.fieldTags.token && {provider.fieldTags.token}} +
+ + {t('integration.botTokenHowToGet')} + +
+ + + + + {t('integration.botTokenEncryptedHint')} + +
+ + {provider.fieldTags.publicKey && ( +
+
+
+ {t('integration.publicKey')} + {provider.fieldTags.publicKey} +
+
+ + + +
+ )} +
+ + {/* Action Bar */} +
+ {hasConfig ? ( + + ) : ( +
+ )} + + + {hasConfig && ( + + )} + + +
+ + {saveResult && ( + + )} + + {testResult && ( + + )} + + {/* Endpoint URL - only shown after config is saved */} + {hasConfig && ( +
+
+
+ {t('integration.endpointUrl')} + {provider.fieldTags.webhook && {provider.fieldTags.webhook}} +
+
+ +
+ {`${origin}/api/agent/webhooks/${provider.id}`} +
+ +
+ }} + i18nKey="integration.endpointUrlHint" + ns="agent" + values={{ name: provider.name }} + /> + } + /> +
+ )} +
+ + ); + }, +); + +export default Body; diff --git a/src/routes/(main)/agent/integration/PlatformDetail/Header.tsx b/src/routes/(main)/agent/integration/PlatformDetail/Header.tsx new file mode 100644 index 0000000000..7c8492a918 --- /dev/null +++ b/src/routes/(main)/agent/integration/PlatformDetail/Header.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { Flexbox, Icon } from '@lobehub/ui'; +import { Switch, Typography } from 'antd'; +import { createStaticStyles } from 'antd-style'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { type IntegrationProvider } from '../const'; + +const { Title, Text } = Typography; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + header: css` + position: sticky; + z-index: 10; + inset-block-start: 0; + + display: flex; + justify-content: center; + + width: 100%; + padding-block: 16px; + padding-inline: 0; + + background: ${cssVar.colorBgContainer}; + `, + headerContent: css` + display: flex; + align-items: center; + justify-content: space-between; + + width: 100%; + max-width: 800px; + padding-block: 0; + padding-inline: 24px; + `, + headerIcon: css` + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + + width: 40px; + height: 40px; + border-radius: 10px; + + color: ${cssVar.colorText}; + + fill: white; + `, +})); + +interface HeaderProps { + currentConfig?: { + enabled: boolean; + }; + onToggleEnable: (enabled: boolean) => void; + provider: IntegrationProvider; +} + +const Header = memo(({ provider, currentConfig, onToggleEnable }) => { + const { t } = useTranslation('agent'); + const ProviderIcon = provider.icon; + + return ( +
+
+ +
+ +
+
+ + + {provider.name} + + + {provider.description} + + +
+
+ + {currentConfig && ( + + + {currentConfig.enabled ? t('integration.enabled') : t('integration.disabled')} + + + + )} +
+
+ ); +}); + +export default Header; diff --git a/src/routes/(main)/agent/integration/PlatformDetail/index.tsx b/src/routes/(main)/agent/integration/PlatformDetail/index.tsx new file mode 100644 index 0000000000..e7202143ee --- /dev/null +++ b/src/routes/(main)/agent/integration/PlatformDetail/index.tsx @@ -0,0 +1,196 @@ +'use client'; + +import { App, Form } from 'antd'; +import { createStaticStyles } from 'antd-style'; +import { memo, useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useAgentStore } from '@/store/agent'; + +import { type IntegrationProvider } from '../const'; +import Body from './Body'; +import Header from './Header'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + main: css` + position: relative; + + overflow-y: auto; + display: flex; + flex: 1; + flex-direction: column; + + background: ${cssVar.colorBgContainer}; + `, +})); + +interface CurrentConfig { + applicationId: string; + credentials: Record; + enabled: boolean; + id: string; + platform: string; +} + +export interface IntegrationFormValues { + applicationId: string; + botToken: string; + publicKey: string; +} + +export interface TestResult { + errorDetail?: string; + type: 'success' | 'error'; +} + +interface PlatformDetailProps { + agentId: string; + currentConfig?: CurrentConfig; + provider: IntegrationProvider; +} + +const PlatformDetail = memo(({ provider, agentId, currentConfig }) => { + const { t } = useTranslation('agent'); + const { message: msg, modal } = App.useApp(); + const [form] = Form.useForm(); + + const [createBotProvider, deleteBotProvider, updateBotProvider, connectBot] = useAgentStore( + (s) => [s.createBotProvider, s.deleteBotProvider, s.updateBotProvider, s.connectBot], + ); + + const [saving, setSaving] = useState(false); + const [saveResult, setSaveResult] = useState(); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(); + + // Reset form when switching platforms + useEffect(() => { + form.resetFields(); + }, [provider.id, form]); + + // Sync form with saved config + useEffect(() => { + if (currentConfig) { + form.setFieldsValue({ + applicationId: currentConfig.applicationId || '', + botToken: currentConfig.credentials?.botToken || '', + publicKey: currentConfig.credentials?.publicKey || '', + }); + } + }, [currentConfig, form]); + + const handleSave = useCallback(async () => { + try { + const values = await form.validateFields(); + + setSaving(true); + setSaveResult(undefined); + + const credentials = { + botToken: values.botToken, + publicKey: values.publicKey || 'default', + }; + + if (currentConfig) { + await updateBotProvider(currentConfig.id, agentId, { + applicationId: values.applicationId, + credentials, + }); + } else { + await createBotProvider({ + agentId, + applicationId: values.applicationId, + credentials, + platform: provider.id, + }); + } + + setSaveResult({ type: 'success' }); + } catch (e: any) { + if (e?.errorFields) return; + console.error(e); + setSaveResult({ errorDetail: e?.message || String(e), type: 'error' }); + } finally { + setSaving(false); + } + }, [agentId, provider.id, form, currentConfig, createBotProvider, updateBotProvider]); + + const handleDelete = useCallback(async () => { + if (!currentConfig) return; + + modal.confirm({ + okButtonProps: { danger: true }, + onOk: async () => { + try { + await deleteBotProvider(currentConfig.id, agentId); + msg.success(t('integration.removed')); + form.resetFields(); + } catch { + msg.error(t('integration.removeFailed')); + } + }, + title: t('integration.deleteConfirm'), + }); + }, [currentConfig, agentId, deleteBotProvider, msg, t, modal, form]); + + const handleToggleEnable = useCallback( + async (enabled: boolean) => { + if (!currentConfig) return; + try { + await updateBotProvider(currentConfig.id, agentId, { enabled }); + } catch { + msg.error(t('integration.updateFailed')); + } + }, + [currentConfig, agentId, updateBotProvider, msg, t], + ); + + const handleTestConnection = useCallback(async () => { + if (!currentConfig) { + msg.warning(t('integration.saveFirstWarning')); + return; + } + + setTesting(true); + setTestResult(undefined); + try { + await connectBot({ + applicationId: currentConfig.applicationId, + platform: provider.id, + }); + setTestResult({ type: 'success' }); + } catch (e: any) { + setTestResult({ + errorDetail: e?.message || String(e), + type: 'error', + }); + } finally { + setTesting(false); + } + }, [currentConfig, provider.id, connectBot, msg, t]); + + return ( +
+
+ msg.success(t('integration.copied'))} + onDelete={handleDelete} + onSave={handleSave} + onTestConnection={handleTestConnection} + /> +
+ ); +}); + +export default PlatformDetail; diff --git a/src/routes/(main)/agent/integration/PlatformList.tsx b/src/routes/(main)/agent/integration/PlatformList.tsx new file mode 100644 index 0000000000..85eb7f4afd --- /dev/null +++ b/src/routes/(main)/agent/integration/PlatformList.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { Icon } from '@lobehub/ui'; +import { createStaticStyles, cx, useTheme } from 'antd-style'; +import { Info } from 'lucide-react'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { IntegrationProvider } from './const'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + root: css` + display: flex; + flex-direction: column; + flex-shrink: 0; + + width: 260px; + border-inline-end: 1px solid ${cssVar.colorBorder}; + `, + item: css` + cursor: pointer; + + display: flex; + gap: 12px; + align-items: center; + + width: 100%; + padding-block: 10px; + padding-inline: 12px; + border: none; + border-radius: ${cssVar.borderRadius}; + + color: ${cssVar.colorTextSecondary}; + text-align: start; + + background: transparent; + + transition: all 0.2s; + + &:hover { + color: ${cssVar.colorText}; + background: ${cssVar.colorFillTertiary}; + } + + &.active { + font-weight: 500; + color: ${cssVar.colorText}; + background: ${cssVar.colorFillSecondary}; + } + `, + list: css` + overflow-y: auto; + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + + padding: 12px; + padding-block-start: 16px; + `, + statusDot: css` + width: 8px; + height: 8px; + border-radius: 50%; + + background: ${cssVar.colorSuccess}; + box-shadow: 0 0 0 1px ${cssVar.colorBgContainer}; + `, + title: css` + padding-inline: 4px; + font-size: 12px; + font-weight: 600; + color: ${cssVar.colorTextQuaternary}; + `, +})); + +interface PlatformListProps { + activeId: string; + connectedPlatforms: Set; + onSelect: (id: string) => void; + providers: IntegrationProvider[]; +} + +const PlatformList = memo( + ({ providers, activeId, connectedPlatforms, onSelect }) => { + const { t } = useTranslation('agent'); + const theme = useTheme(); + + return ( + + ); + }, +); + +export default PlatformList; diff --git a/src/routes/(main)/agent/integration/const.ts b/src/routes/(main)/agent/integration/const.ts new file mode 100644 index 0000000000..ab86610588 --- /dev/null +++ b/src/routes/(main)/agent/integration/const.ts @@ -0,0 +1,35 @@ +import { SiDiscord } from '@icons-pack/react-simple-icons'; +import type { LucideIcon } from 'lucide-react'; +import type { FC } from 'react'; + +export interface IntegrationProvider { + color: string; + description: string; + docsLink: string; + fieldTags: { + appId: string; + publicKey?: string; + token: string; + webhook: string; + }; + icon: FC | LucideIcon; + id: string; + name: string; +} + +export const INTEGRATION_PROVIDERS: IntegrationProvider[] = [ + { + color: '#5865F2', + description: 'Connect this assistant to Discord server for channel chat and direct messages.', + docsLink: 'https://discord.com/developers/docs/intro', + fieldTags: { + appId: 'Application ID', + publicKey: 'Public Key', + token: 'Bot Token', + webhook: 'Interactions Endpoint URL', + }, + icon: SiDiscord, + id: 'discord', + name: 'Discord', + }, +]; diff --git a/src/routes/(main)/agent/integration/index.tsx b/src/routes/(main)/agent/integration/index.tsx new file mode 100644 index 0000000000..e0703f47f6 --- /dev/null +++ b/src/routes/(main)/agent/integration/index.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { Flexbox } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { memo, useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import Loading from '@/components/Loading/BrandTextLoading'; +import NavHeader from '@/features/NavHeader'; +import { useAgentStore } from '@/store/agent'; + +import { INTEGRATION_PROVIDERS } from './const'; +import PlatformDetail from './PlatformDetail'; +import PlatformList from './PlatformList'; + +const styles = createStaticStyles(({ css }) => ({ + container: css` + overflow: hidden; + display: flex; + flex: 1; + + width: 100%; + height: 100%; + `, +})); + +const IntegrationPage = memo(() => { + const { aid } = useParams<{ aid?: string }>(); + const [activeProviderId, setActiveProviderId] = useState(INTEGRATION_PROVIDERS[0].id); + + const { data: providers, isLoading } = useAgentStore((s) => s.useFetchBotProviders(aid)); + + const connectedPlatforms = useMemo( + () => new Set(providers?.map((p) => p.platform) ?? []), + [providers], + ); + + const activeProvider = useMemo( + () => INTEGRATION_PROVIDERS.find((p) => p.id === activeProviderId) || INTEGRATION_PROVIDERS[0], + [activeProviderId], + ); + + const currentConfig = useMemo( + () => providers?.find((p) => p.platform === activeProviderId), + [providers, activeProviderId], + ); + + if (!aid) return null; + + return ( + + + + {isLoading && } + + {!isLoading && ( +
+ + +
+ )} +
+
+ ); +}); + +export default IntegrationPage; diff --git a/src/server/modules/AgentRuntime/RuntimeExecutors.ts b/src/server/modules/AgentRuntime/RuntimeExecutors.ts index 4ea6b5eea1..c8262c8867 100644 --- a/src/server/modules/AgentRuntime/RuntimeExecutors.ts +++ b/src/server/modules/AgentRuntime/RuntimeExecutors.ts @@ -39,6 +39,7 @@ export interface RuntimeExecutorContext { operationId: string; serverDB: LobeChatDatabase; stepIndex: number; + stream?: boolean; streamManager: IStreamEventManager; toolExecutionService: ToolExecutionService; topicId?: string; @@ -105,11 +106,7 @@ export const createRuntimeExecutors = ( // Publish stream start event await streamManager.publishStreamEvent(operationId, { - data: { - assistantMessage: assistantMessageItem, - model, - provider, - }, + data: { assistantMessage: assistantMessageItem, model, provider }, stepIndex, type: 'stream_start', }); @@ -194,7 +191,8 @@ export const createRuntimeExecutors = ( const modelRuntime = await initModelRuntimeFromDB(ctx.serverDB, ctx.userId!, provider); // Construct ChatStreamPayload - const chatPayload = { messages: processedMessages, model, tools }; + const stream = ctx.stream ?? true; + const chatPayload = { messages: processedMessages, model, stream, tools }; log( `${stagePrefix} calling model-runtime chat (model: %s, messages: %d, tools: %d)`, @@ -797,16 +795,14 @@ export const createRuntimeExecutors = ( events.push({ id: chatToolPayload.id, result: executionResult, type: 'tool_result' }); - // Accumulate usage + // Collect per-tool usage for post-batch accumulation const toolCost = TOOL_PRICING[toolName] || 0; - UsageCounter.accumulateTool({ - cost: state.cost, + toolResults.at(-1).usageParams = { executionTime, success: isSuccess, toolCost, toolName, - usage: state.usage, - }); + }; } catch (error) { console.error(`[${operationLogId}] Tool execution failed for ${toolName}:`, error); @@ -829,8 +825,21 @@ export const createRuntimeExecutors = ( `[${operationLogId}][call_tools_batch] All tools executed, created ${toolMessageIds.length} tool messages`, ); - // Refresh messages from database to ensure state is in sync + // Accumulate tool usage sequentially after all tools have finished const newState = structuredClone(state); + for (const result of toolResults) { + if (result.usageParams) { + const { usage, cost } = UsageCounter.accumulateTool({ + ...result.usageParams, + cost: newState.cost, + usage: newState.usage, + }); + newState.usage = usage; + if (cost) newState.cost = cost; + } + } + + // Refresh messages from database to ensure state is in sync // Query latest messages from database // Must pass agentId to ensure correct query scope, otherwise when topicId is undefined, diff --git a/src/server/modules/AgentRuntime/__tests__/RuntimeExecutors.test.ts b/src/server/modules/AgentRuntime/__tests__/RuntimeExecutors.test.ts index 328b415e29..ca8e0ebc0e 100644 --- a/src/server/modules/AgentRuntime/__tests__/RuntimeExecutors.test.ts +++ b/src/server/modules/AgentRuntime/__tests__/RuntimeExecutors.test.ts @@ -1514,6 +1514,72 @@ describe('RuntimeExecutors', () => { // The next call_llm step needs messages to work properly expect(result.newState.messages.length).toBeGreaterThan(0); }); + + it('should accumulate tool usage in newState after batch execution', async () => { + mockToolExecutionService.executeTool + .mockResolvedValueOnce({ + content: 'Search result', + error: null, + executionTime: 150, + state: {}, + success: true, + }) + .mockResolvedValueOnce({ + content: 'Crawl result', + error: null, + executionTime: 250, + state: {}, + success: true, + }); + + const executors = createRuntimeExecutors(ctx); + const state = createMockState(); + + const instruction = { + payload: { + parentMessageId: 'assistant-msg-123', + toolsCalling: [ + { + apiName: 'search', + arguments: '{"query": "test"}', + id: 'tool-call-1', + identifier: 'web-search', + type: 'default' as const, + }, + { + apiName: 'crawl', + arguments: '{"url": "https://example.com"}', + id: 'tool-call-2', + identifier: 'web-browsing', + type: 'default' as const, + }, + ], + }, + type: 'call_tools_batch' as const, + }; + + const result = await executors.call_tools_batch!(instruction, state); + + // Tool usage must be accumulated in newState + expect(result.newState.usage.tools.totalCalls).toBe(2); + expect(result.newState.usage.tools.totalTimeMs).toBe(400); + expect(result.newState.usage.tools.byTool).toHaveLength(2); + + // Verify per-tool breakdown + const searchTool = result.newState.usage.tools.byTool.find( + (t: any) => t.name === 'web-search/search', + ); + const crawlTool = result.newState.usage.tools.byTool.find( + (t: any) => t.name === 'web-browsing/crawl', + ); + expect(searchTool).toEqual( + expect.objectContaining({ calls: 1, errors: 0, totalTimeMs: 150 }), + ); + expect(crawlTool).toEqual(expect.objectContaining({ calls: 1, errors: 0, totalTimeMs: 250 })); + + // Original state must not be mutated + expect(state.usage.tools.totalCalls).toBe(0); + }); }); describe('resolve_aborted_tools executor', () => { diff --git a/src/server/routers/lambda/agentBotProvider.ts b/src/server/routers/lambda/agentBotProvider.ts new file mode 100644 index 0000000000..98b0421492 --- /dev/null +++ b/src/server/routers/lambda/agentBotProvider.ts @@ -0,0 +1,81 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { AgentBotProviderModel } from '@/database/models/agentBotProvider'; +import { authedProcedure, router } from '@/libs/trpc/lambda'; +import { serverDatabase } from '@/libs/trpc/lambda/middleware'; +import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt'; +import { GatewayService } from '@/server/services/gateway'; + +const agentBotProviderProcedure = authedProcedure.use(serverDatabase).use(async (opts) => { + const { ctx } = opts; + const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); + + return opts.next({ + ctx: { + agentBotProviderModel: new AgentBotProviderModel(ctx.serverDB, ctx.userId, gateKeeper), + }, + }); +}); + +export const agentBotProviderRouter = router({ + create: agentBotProviderProcedure + .input( + z.object({ + agentId: z.string(), + applicationId: z.string(), + credentials: z.record(z.string()), + enabled: z.boolean().optional(), + platform: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + try { + return await ctx.agentBotProviderModel.create(input); + } catch (e: any) { + if (e?.cause?.code === '23505') { + throw new TRPCError({ + code: 'CONFLICT', + message: `A bot with application ID "${input.applicationId}" is already registered on ${input.platform}. Each application ID can only be used once.`, + }); + } + throw e; + } + }), + + delete: agentBotProviderProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + return ctx.agentBotProviderModel.delete(input.id); + }), + + getByAgentId: agentBotProviderProcedure + .input(z.object({ agentId: z.string() })) + .query(async ({ input, ctx }) => { + return ctx.agentBotProviderModel.findByAgentId(input.agentId); + }), + + connectBot: agentBotProviderProcedure + .input(z.object({ applicationId: z.string(), platform: z.string() })) + .mutation(async ({ input, ctx }) => { + const service = new GatewayService(); + const status = await service.startBot(input.platform, input.applicationId, ctx.userId); + + return { status }; + }), + + update: agentBotProviderProcedure + .input( + z.object({ + applicationId: z.string().optional(), + credentials: z.record(z.string()).optional(), + enabled: z.boolean().optional(), + id: z.string(), + platform: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + const { id, ...value } = input; + return ctx.agentBotProviderModel.update(id, value); + }), +}); diff --git a/src/server/routers/lambda/agentGroup.ts b/src/server/routers/lambda/agentGroup.ts index fadf4c9454..01b3dba97f 100644 --- a/src/server/routers/lambda/agentGroup.ts +++ b/src/server/routers/lambda/agentGroup.ts @@ -5,12 +5,40 @@ import { AgentModel } from '@/database/models/agent'; import { ChatGroupModel } from '@/database/models/chatGroup'; import { UserModel } from '@/database/models/user'; import { AgentGroupRepository } from '@/database/repositories/agentGroup'; -import { insertAgentSchema } from '@/database/schemas'; import { type ChatGroupConfig } from '@/database/types/chatGroup'; import { authedProcedure, router } from '@/libs/trpc/lambda'; import { serverDatabase } from '@/libs/trpc/lambda/middleware'; import { AgentGroupService } from '@/server/services/agentGroup'; +/** + * Custom schema for agent member input, replacing drizzle-generated insertAgentSchema + * to avoid Json type inference issues with jsonb columns. + */ +const agentMemberInputSchema = z + .object({ + agencyConfig: z.any().nullish(), + avatar: z.string().nullish(), + backgroundColor: z.string().nullish(), + clientId: z.string().nullish(), + description: z.string().nullish(), + editorData: z.any().nullish(), + fewShots: z.any().nullish(), + id: z.string().optional(), + marketIdentifier: z.string().nullish(), + model: z.string().nullish(), + params: z.any().nullish(), + pinned: z.boolean().nullish(), + plugins: z.array(z.string()).nullish(), + provider: z.string().nullish(), + sessionGroupId: z.string().nullish(), + slug: z.string().nullish(), + systemRole: z.string().nullish(), + tags: z.array(z.string()).nullish(), + title: z.string().nullish(), + virtual: z.boolean().nullish(), + }) + .partial(); + const agentGroupProcedure = authedProcedure.use(serverDatabase).use(async (opts) => { const { ctx } = opts; @@ -44,17 +72,7 @@ export const agentGroupRouter = router({ batchCreateAgentsInGroup: agentGroupProcedure .input( z.object({ - agents: z.array( - insertAgentSchema - .omit({ - chatConfig: true, - openingMessage: true, - openingQuestions: true, - tts: true, - userId: true, - }) - .partial(), - ), + agents: z.array(agentMemberInputSchema), groupId: z.string(), }), ) @@ -118,17 +136,7 @@ export const agentGroupRouter = router({ .input( z.object({ groupConfig: InsertChatGroupSchema, - members: z.array( - insertAgentSchema - .omit({ - chatConfig: true, - openingMessage: true, - openingQuestions: true, - tts: true, - userId: true, - }) - .partial(), - ), + members: z.array(agentMemberInputSchema), supervisorConfig: z .object({ avatar: z.string().nullish(), diff --git a/src/server/routers/lambda/index.ts b/src/server/routers/lambda/index.ts index 2ae3401a16..285b0f9030 100644 --- a/src/server/routers/lambda/index.ts +++ b/src/server/routers/lambda/index.ts @@ -9,6 +9,7 @@ import { topUpRouter } from '@/business/server/lambda-routers/topUp'; import { publicProcedure, router } from '@/libs/trpc/lambda'; import { agentRouter } from './agent'; +import { agentBotProviderRouter } from './agentBotProvider'; import { agentCronJobRouter } from './agentCronJob'; import { agentEvalRouter } from './agentEval'; import { agentGroupRouter } from './agentGroup'; @@ -53,6 +54,7 @@ import { videoRouter } from './video'; export const lambdaRouter = router({ agent: agentRouter, + agentBotProvider: agentBotProviderRouter, agentCronJob: agentCronJobRouter, agentEval: agentEvalRouter, agentSkills: agentSkillsRouter, diff --git a/src/server/services/agentRuntime/AgentRuntimeService.test.ts b/src/server/services/agentRuntime/AgentRuntimeService.test.ts index d849d4e14f..dcfc6f1618 100644 --- a/src/server/services/agentRuntime/AgentRuntimeService.test.ts +++ b/src/server/services/agentRuntime/AgentRuntimeService.test.ts @@ -578,6 +578,197 @@ describe('AgentRuntimeService', () => { }); }); + describe('executeStep - tool result extraction', () => { + const mockParams: AgentExecutionParams = { + operationId: 'test-operation-1', + stepIndex: 1, + context: { + phase: 'user_input', + payload: { + message: { content: 'test' }, + sessionId: 'test-operation-1', + isFirstMessage: false, + }, + session: { + sessionId: 'test-operation-1', + status: 'running', + stepCount: 1, + messageCount: 1, + }, + }, + }; + + const mockState = { + operationId: 'test-operation-1', + status: 'running', + stepCount: 1, + messages: [], + events: [], + lastModified: new Date().toISOString(), + }; + + const mockMetadata = { + userId: 'user-123', + agentConfig: { name: 'test-agent' }, + modelRuntimeConfig: { model: 'gpt-4' }, + createdAt: new Date().toISOString(), + lastActiveAt: new Date().toISOString(), + status: 'running', + totalCost: 0, + totalSteps: 1, + }; + + beforeEach(() => { + mockCoordinator.loadAgentState.mockResolvedValue(mockState); + mockCoordinator.getOperationMetadata.mockResolvedValue(mockMetadata); + }); + + it('should extract tool output from data field for single tool_result', async () => { + const mockOnAfterStep = vi.fn(); + service.registerStepCallbacks('test-operation-1', { onAfterStep: mockOnAfterStep }); + + const mockStepResult = { + newState: { ...mockState, stepCount: 2, status: 'running' }, + nextContext: { + phase: 'tool_result', + payload: { + data: 'Search found 3 results for "weather"', + executionTime: 120, + isSuccess: true, + toolCall: { identifier: 'lobe-web-browsing', apiName: 'search', id: 'tc-1' }, + toolCallId: 'tc-1', + }, + session: { + sessionId: 'test-operation-1', + status: 'running', + stepCount: 2, + messageCount: 2, + }, + }, + events: [], + }; + + const mockRuntime = { step: vi.fn().mockResolvedValue(mockStepResult) }; + vi.spyOn(service as any, 'createAgentRuntime').mockReturnValue({ runtime: mockRuntime }); + + await service.executeStep(mockParams); + + expect(mockOnAfterStep).toHaveBeenCalledWith( + expect.objectContaining({ + toolsResult: [ + expect.objectContaining({ + apiName: 'search', + identifier: 'lobe-web-browsing', + output: 'Search found 3 results for "weather"', + }), + ], + }), + ); + }); + + it('should extract tool output from data field for tools_batch_result', async () => { + const mockOnAfterStep = vi.fn(); + service.registerStepCallbacks('test-operation-1', { onAfterStep: mockOnAfterStep }); + + const mockStepResult = { + newState: { ...mockState, stepCount: 2, status: 'running' }, + nextContext: { + phase: 'tools_batch_result', + payload: { + parentMessageId: 'msg-1', + toolCount: 2, + toolResults: [ + { + data: 'Result from tool A', + executionTime: 100, + isSuccess: true, + toolCall: { identifier: 'builtin', apiName: 'searchA', id: 'tc-1' }, + toolCallId: 'tc-1', + }, + { + data: { items: [1, 2, 3] }, + executionTime: 200, + isSuccess: true, + toolCall: { identifier: 'lobe-skills', apiName: 'runSkill', id: 'tc-2' }, + toolCallId: 'tc-2', + }, + ], + }, + session: { + sessionId: 'test-operation-1', + status: 'running', + stepCount: 2, + messageCount: 3, + }, + }, + events: [], + }; + + const mockRuntime = { step: vi.fn().mockResolvedValue(mockStepResult) }; + vi.spyOn(service as any, 'createAgentRuntime').mockReturnValue({ runtime: mockRuntime }); + + await service.executeStep(mockParams); + + expect(mockOnAfterStep).toHaveBeenCalledWith( + expect.objectContaining({ + toolsResult: [ + expect.objectContaining({ + apiName: 'searchA', + identifier: 'builtin', + output: 'Result from tool A', + }), + expect.objectContaining({ + apiName: 'runSkill', + identifier: 'lobe-skills', + output: JSON.stringify({ items: [1, 2, 3] }), + }), + ], + }), + ); + }); + + it('should handle tool result with undefined data', async () => { + const mockOnAfterStep = vi.fn(); + service.registerStepCallbacks('test-operation-1', { onAfterStep: mockOnAfterStep }); + + const mockStepResult = { + newState: { ...mockState, stepCount: 2, status: 'running' }, + nextContext: { + phase: 'tool_result', + payload: { + data: undefined, + toolCall: { identifier: 'builtin', apiName: 'noop', id: 'tc-1' }, + toolCallId: 'tc-1', + }, + session: { + sessionId: 'test-operation-1', + status: 'running', + stepCount: 2, + messageCount: 2, + }, + }, + events: [], + }; + + const mockRuntime = { step: vi.fn().mockResolvedValue(mockStepResult) }; + vi.spyOn(service as any, 'createAgentRuntime').mockReturnValue({ runtime: mockRuntime }); + + await service.executeStep(mockParams); + + expect(mockOnAfterStep).toHaveBeenCalledWith( + expect.objectContaining({ + toolsResult: [ + expect.objectContaining({ + apiName: 'noop', + identifier: 'builtin', + output: undefined, + }), + ], + }), + ); + }); + }); + describe('getOperationStatus', () => { const mockState = { operationId: 'test-operation-1', diff --git a/src/server/services/agentRuntime/AgentRuntimeService.ts b/src/server/services/agentRuntime/AgentRuntimeService.ts index 5424561763..c50af2339a 100644 --- a/src/server/services/agentRuntime/AgentRuntimeService.ts +++ b/src/server/services/agentRuntime/AgentRuntimeService.ts @@ -31,6 +31,7 @@ import { type StartExecutionResult, type StepCompletionReason, type StepLifecycleCallbacks, + type StepPresentationData, } from './types'; if (process.env.VERCEL) { @@ -251,6 +252,7 @@ export class AgentRuntimeService { modelRuntimeConfig, userId, autoStart = true, + stream, tools, initialMessages = [], appContext, @@ -259,6 +261,8 @@ export class AgentRuntimeService { stepCallbacks, userInterventionConfig, completionWebhook, + stepWebhook, + webhookDelivery, evalContext, maxSteps, } = params; @@ -280,7 +284,10 @@ export class AgentRuntimeService { evalContext, // need be removed modelRuntimeConfig, + stepWebhook, + stream, userId, + webhookDelivery, workingDirectory: agentConfig?.chatConfig?.localSystem?.workingDirectory, ...appContext, }, @@ -514,78 +521,156 @@ export class AgentRuntimeService { type: 'step_complete', }); - // Build enhanced step completion log + // Build enhanced step completion log & presentation data const { usage, cost } = stepResult.newState; const phase = stepResult.nextContext?.phase; + const isToolPhase = phase === 'tool_result' || phase === 'tools_batch_result'; + + // --- Extract presentation fields from step result --- + let content: string | undefined; + let reasoning: string | undefined; + let toolsCalling: + | Array<{ apiName: string; arguments?: string; identifier: string }> + | undefined; + let toolsResult: Array<{ apiName: string; identifier: string; output?: string }> | undefined; let stepSummary: string; if (phase === 'tool_result') { const toolPayload = stepResult.nextContext?.payload as any; const toolCall = toolPayload?.toolCall; - const toolName = toolCall ? `${toolCall.identifier}/${toolCall.apiName}` : 'unknown'; - stepSummary = `[tool] ${toolName}`; + const identifier = toolCall?.identifier || 'unknown'; + const apiName = toolCall?.apiName || 'unknown'; + const output = toolPayload?.data; + toolsResult = [ + { + apiName, + identifier, + output: + typeof output === 'string' + ? output + : output != null + ? JSON.stringify(output) + : undefined, + }, + ]; + stepSummary = `[tool] ${identifier}/${apiName}`; } else if (phase === 'tools_batch_result') { const nextPayload = stepResult.nextContext?.payload as any; const toolCount = nextPayload?.toolCount || 0; - const toolResults = nextPayload?.toolResults || []; - const toolNames = toolResults.map((r: any) => { - const tc = r.toolCall; - return tc ? `${tc.identifier}/${tc.apiName}` : 'unknown'; - }); + const rawToolResults = nextPayload?.toolResults || []; + const mappedResults: Array<{ apiName: string; identifier: string; output?: string }> = + rawToolResults.map((r: any) => { + const tc = r.toolCall; + const output = r.data; + return { + apiName: tc?.apiName || 'unknown', + identifier: tc?.identifier || 'unknown', + output: + typeof output === 'string' + ? output + : output != null + ? JSON.stringify(output) + : undefined, + }; + }); + toolsResult = mappedResults; + const toolNames = mappedResults.map((r) => `${r.identifier}/${r.apiName}`); stepSummary = `[tools×${toolCount}] ${toolNames.join(', ')}`; } else { // LLM result const llmEvent = stepResult.events?.find((e) => e.type === 'llm_result'); - const content = (llmEvent as any)?.result?.content || ''; - const reasoning = (llmEvent as any)?.result?.reasoning || ''; - const toolCalling = (llmEvent as any)?.result?.tool_calls; - const hasToolCalls = Array.isArray(toolCalling) && toolCalling.length > 0; + content = (llmEvent as any)?.result?.content || undefined; + reasoning = (llmEvent as any)?.result?.reasoning || undefined; + + // Use parsed ChatToolPayload from payload (has identifier + apiName) + const payloadToolsCalling = (stepResult.nextContext?.payload as any)?.toolsCalling as + | Array<{ apiName: string; arguments: string; identifier: string }> + | undefined; + const hasToolCalls = Array.isArray(payloadToolsCalling) && payloadToolsCalling.length > 0; + + if (hasToolCalls) { + toolsCalling = payloadToolsCalling.map((tc) => ({ + apiName: tc.apiName, + arguments: tc.arguments, + identifier: tc.identifier, + })); + } const parts: string[] = []; - - // Thinking preview if (reasoning) { const thinkPreview = reasoning.length > 30 ? reasoning.slice(0, 30) + '...' : reasoning; parts.push(`💭 "${thinkPreview}"`); } - if (!content && hasToolCalls) { - const names = toolCalling.map((tc: any) => tc.function?.name || 'unknown'); - parts.push(`→ call tools: ${names.join(', ')}`); + parts.push( + `→ call tools: ${toolsCalling!.map((tc) => `${tc.identifier}|${tc.apiName}`).join(', ')}`, + ); } else if (content) { const preview = content.length > 20 ? content.slice(0, 20) + '...' : content; parts.push(`"${preview}"`); } - - stepSummary = `[llm] ${parts.join(' | ') || '(empty)'}`; + if (parts.length > 0) { + stepSummary = `[llm] ${parts.join(' | ')}`; + } else { + stepSummary = `[llm] (empty) result: ${JSON.stringify(stepResult, null, 2)}`; + } } - const rawTokens = usage?.llm?.tokens?.total ?? 0; - const totalTokens = - rawTokens >= 1_000_000 - ? `${(rawTokens / 1_000_000).toFixed(1)}m` - : rawTokens >= 1000 - ? `${(rawTokens / 1000).toFixed(1)}k` - : String(rawTokens); - const totalCost = (cost?.total ?? 0).toFixed(4); + // --- Step-level usage from nextContext.stepUsage --- + const stepUsage = stepResult.nextContext?.stepUsage as Record | undefined; + + // --- Cumulative usage --- + const tokens = usage?.llm?.tokens; + const totalInputTokens = tokens?.input ?? 0; + const totalOutputTokens = tokens?.output ?? 0; + const totalTokensNum = tokens?.total ?? 0; + const totalCostNum = cost?.total ?? 0; + + const totalTokensStr = + totalTokensNum >= 1_000_000 + ? `${(totalTokensNum / 1_000_000).toFixed(1)}m` + : totalTokensNum >= 1000 + ? `${(totalTokensNum / 1000).toFixed(1)}k` + : String(totalTokensNum); const llmCalls = usage?.llm?.apiCalls ?? 0; - const toolCalls = usage?.tools?.totalCalls ?? 0; + const toolCallCount = usage?.tools?.totalCalls ?? 0; log( '[%s][%d] completed %s | total: %s tokens / $%s | llm×%d | tools×%d', operationId, stepIndex, stepSummary, - totalTokens, - totalCost, + totalTokensStr, + totalCostNum.toFixed(4), llmCalls, - toolCalls, + toolCallCount, ); - // Call onAfterStep callback + // Build presentation data object for callbacks and webhooks + const stepPresentationData: StepPresentationData = { + content, + executionTimeMs: Date.now() - startAt, + reasoning, + stepCost: stepUsage?.cost ?? undefined, + stepInputTokens: stepUsage?.totalInputTokens ?? undefined, + stepOutputTokens: stepUsage?.totalOutputTokens ?? undefined, + stepTotalTokens: stepUsage?.totalTokens ?? undefined, + stepType: isToolPhase ? ('call_tool' as const) : ('call_llm' as const), + thinking: !isToolPhase, + toolsCalling, + toolsResult, + totalCost: totalCostNum, + totalInputTokens, + totalOutputTokens, + totalSteps: stepResult.newState.stepCount ?? 0, + totalTokens: totalTokensNum, + }; + + // Call onAfterStep callback with presentation data if (callbacks?.onAfterStep) { try { await callbacks.onAfterStep({ + ...stepPresentationData, operationId, shouldContinue, state: stepResult.newState, @@ -597,6 +682,36 @@ export class AgentRuntimeService { } } + // Update step tracking in state metadata and trigger step webhook + if (stepResult.newState.metadata?.stepWebhook) { + const prevTracking = stepResult.newState.metadata._stepTracking || {}; + const newTotalToolCalls = (prevTracking.totalToolCalls ?? 0) + (toolsCalling?.length ?? 0); + + // Truncate content to 1800 chars to match Discord message limits + const truncatedContent = content + ? content.length > 1800 + ? content.slice(0, 1800) + '...' + : content + : prevTracking.lastLLMContent; + + const updatedTracking = { + lastLLMContent: truncatedContent, + lastToolsCalling: toolsCalling || prevTracking.lastToolsCalling, + totalToolCalls: newTotalToolCalls, + }; + + // Persist tracking state for next step + stepResult.newState.metadata._stepTracking = updatedTracking; + await this.coordinator.saveAgentState(operationId, stepResult.newState); + + // Fire step webhook + await this.triggerStepWebhook( + stepResult.newState, + operationId, + stepPresentationData as unknown as Record, + ); + } + if (shouldContinue && stepResult.nextContext && this.queueService) { const nextStepIndex = stepIndex + 1; const delay = this.calculateStepDelay(stepResult); @@ -1047,6 +1162,7 @@ export class AgentRuntimeService { operationId, serverDB: this.serverDB, stepIndex, + stream: metadata?.stream, streamManager: this.streamManager, toolExecutionService: this.toolExecutionService, topicId: metadata?.topicId, @@ -1085,6 +1201,41 @@ export class AgentRuntimeService { return { newState: state, nextContext: undefined }; } + /** + * Deliver a webhook payload via fetch or QStash. + * Fire-and-forget: errors are logged but never thrown. + */ + private async deliverWebhook( + url: string, + payload: Record, + delivery: 'fetch' | 'qstash' = 'fetch', + operationId: string, + ): Promise { + try { + if (delivery === 'qstash') { + const { Client } = await import('@upstash/qstash'); + const client = new Client({ token: process.env.QSTASH_TOKEN! }); + await client.publishJSON({ + body: payload, + headers: { + ...(process.env.VERCEL_AUTOMATION_BYPASS_SECRET && { + 'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET, + }), + }, + url, + }); + } else { + await fetch(url, { + body: JSON.stringify(payload), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + } + } catch (error) { + console.error('[%s] Webhook delivery failed (%s → %s):', operationId, delivery, url, error); + } + } + /** * Trigger completion webhook if configured in state metadata. * Fire-and-forget: errors are logged but never thrown. @@ -1097,34 +1248,79 @@ export class AgentRuntimeService { const webhook = state.metadata?.completionWebhook; if (!webhook?.url) return; - try { - log('[%s] Triggering completion webhook: %s', operationId, webhook.url); + log('[%s] Triggering completion webhook: %s', operationId, webhook.url); - const duration = state.createdAt - ? Date.now() - new Date(state.createdAt).getTime() - : undefined; + const duration = state.createdAt ? Date.now() - new Date(state.createdAt).getTime() : undefined; - await fetch(webhook.url, { - body: JSON.stringify({ - ...webhook.body, - cost: state.cost?.total, - duration, - errorDetail: state.error, - errorMessage: this.extractErrorMessage(state.error), - llmCalls: state.usage?.llm?.apiCalls, - operationId, - reason, - status: state.status, - steps: state.stepCount, - toolCalls: state.usage?.tools?.totalCalls, - totalTokens: state.usage?.llm?.tokens?.total, - }), - headers: { 'Content-Type': 'application/json' }, - method: 'POST', - }); - } catch (error) { - console.error('[%s] Completion webhook failed:', operationId, error); - } + // Extract last assistant content from state messages + const lastAssistantContent = state.messages + ?.slice() + .reverse() + .find( + (m: { content?: string; role: string }) => m.role === 'assistant' && m.content, + )?.content; + + const delivery = state.metadata?.webhookDelivery || 'fetch'; + + await this.deliverWebhook( + webhook.url, + { + ...webhook.body, + cost: state.cost?.total, + duration, + errorDetail: state.error, + errorMessage: this.extractErrorMessage(state.error), + lastAssistantContent, + llmCalls: state.usage?.llm?.apiCalls, + operationId, + reason, + status: state.status, + steps: state.stepCount, + toolCalls: state.usage?.tools?.totalCalls, + totalTokens: state.usage?.llm?.tokens?.total, + type: 'completion', + }, + delivery, + operationId, + ); + } + + /** + * Trigger step webhook if configured in state metadata. + * Reads accumulated step tracking data and fires webhook with step presentation data. + * Fire-and-forget: errors are logged but never thrown. + */ + private async triggerStepWebhook( + state: any, + operationId: string, + presentationData: Record, + ): Promise { + const webhook = state.metadata?.stepWebhook; + if (!webhook?.url) return; + + log('[%s] Triggering step webhook: %s', operationId, webhook.url); + + const tracking = state.metadata?._stepTracking || {}; + const delivery = state.metadata?.webhookDelivery || 'fetch'; + const elapsedMs = state.createdAt + ? Date.now() - new Date(state.createdAt).getTime() + : undefined; + + await this.deliverWebhook( + webhook.url, + { + ...webhook.body, + ...presentationData, + elapsedMs, + lastLLMContent: tracking.lastLLMContent, + lastToolsCalling: tracking.lastToolsCalling, + operationId, + totalToolCalls: tracking.totalToolCalls ?? 0, + type: 'step', + }, + delivery, + operationId, + ); } /** diff --git a/src/server/services/agentRuntime/types.ts b/src/server/services/agentRuntime/types.ts index 6e6b78bfa1..a526cc0ffe 100644 --- a/src/server/services/agentRuntime/types.ts +++ b/src/server/services/agentRuntime/types.ts @@ -8,17 +8,54 @@ import { type UserInterventionConfig } from '@lobechat/types'; * Step execution lifecycle callbacks * Used to inject custom logic at different stages of step execution */ +export interface StepPresentationData { + /** LLM text output (undefined if this was a tool step) */ + content?: string; + /** This step's execution time in ms */ + executionTimeMs: number; + /** LLM reasoning / thinking content (undefined if none) */ + reasoning?: string; + /** This step's cost (LLM steps only) */ + stepCost?: number; + /** This step's input tokens (LLM steps only) */ + stepInputTokens?: number; + /** This step's output tokens (LLM steps only) */ + stepOutputTokens?: number; + /** This step's total tokens (LLM steps only) */ + stepTotalTokens?: number; + /** What this step executed */ + stepType: 'call_llm' | 'call_tool'; + /** true = next step is LLM thinking; false = next step is tool execution */ + thinking: boolean; + /** Tools the LLM decided to call (undefined if no tool calls) */ + toolsCalling?: Array<{ apiName: string; arguments?: string; identifier: string }>; + /** Results from tool execution (only for call_tool steps) */ + toolsResult?: Array<{ apiName: string; identifier: string; output?: string }>; + /** Cumulative total cost */ + totalCost: number; + /** Cumulative input tokens */ + totalInputTokens: number; + /** Cumulative output tokens */ + totalOutputTokens: number; + /** Total steps executed so far */ + totalSteps: number; + /** Cumulative total tokens */ + totalTokens: number; +} + export interface StepLifecycleCallbacks { /** * Called after step execution */ - onAfterStep?: (params: { - operationId: string; - shouldContinue: boolean; - state: AgentState; - stepIndex: number; - stepResult: any; - }) => Promise; + onAfterStep?: ( + params: StepPresentationData & { + operationId: string; + shouldContinue: boolean; + state: AgentState; + stepIndex: number; + stepResult: any; + }, + ) => Promise; /** * Called before step execution @@ -103,6 +140,20 @@ export interface OperationCreationParams { * Used to inject custom logic at different stages of step execution */ stepCallbacks?: StepLifecycleCallbacks; + /** + * Step webhook configuration + * When set, an HTTP POST will be fired after each step completes. + * Persisted in Redis state so it survives across QStash step boundaries. + */ + stepWebhook?: { + body?: Record; + url: string; + }; + /** + * Whether the LLM call should use streaming. + * Defaults to true. Set to false for non-streaming scenarios (e.g., bot integrations). + */ + stream?: boolean; toolManifestMap: Record; tools?: any[]; toolSourceMap?: Record; @@ -113,6 +164,12 @@ export interface OperationCreationParams { * Use { approvalMode: 'headless' } for async tasks that should never wait for human approval */ userInterventionConfig?: UserInterventionConfig; + /** + * Webhook delivery method. + * - 'fetch': plain HTTP POST (default) + * - 'qstash': deliver via QStash publishJSON for guaranteed delivery + */ + webhookDelivery?: 'fetch' | 'qstash'; } export interface OperationCreationResult { diff --git a/src/server/services/aiAgent/index.ts b/src/server/services/aiAgent/index.ts index 6b5d0220f1..f87fc4ffc5 100644 --- a/src/server/services/aiAgent/index.ts +++ b/src/server/services/aiAgent/index.ts @@ -4,6 +4,7 @@ import { LOADING_FLAT } from '@lobechat/const'; import { type LobeToolManifest } from '@lobechat/context-engine'; import { type LobeChatDatabase } from '@lobechat/database'; import { + type ChatTopicBotContext, type ExecAgentParams, type ExecAgentResult, type ExecGroupAgentParams, @@ -64,6 +65,8 @@ function formatErrorForMetadata(error: unknown): Record | undefined * This extends the public ExecAgentParams with server-side only options */ interface InternalExecAgentParams extends ExecAgentParams { + /** Bot context for topic metadata (platform, applicationId, platformThreadId) */ + botContext?: ChatTopicBotContext; /** * Completion webhook configuration * Persisted in Redis state, triggered via HTTP POST when the operation completes. @@ -80,6 +83,19 @@ interface InternalExecAgentParams extends ExecAgentParams { maxSteps?: number; /** Step lifecycle callbacks for operation tracking (server-side only) */ stepCallbacks?: StepLifecycleCallbacks; + /** + * Step webhook configuration + * Persisted in Redis state, triggered via HTTP POST after each step completes. + */ + stepWebhook?: { + body?: Record; + url: string; + }; + /** + * Whether the LLM call should use streaming. + * Defaults to true. Set to false for non-streaming scenarios (e.g., bot integrations). + */ + stream?: boolean; /** Topic creation trigger source ('cron' | 'chat' | 'api') */ trigger?: string; /** @@ -87,6 +103,12 @@ interface InternalExecAgentParams extends ExecAgentParams { * Use { approvalMode: 'headless' } for async tasks that should never wait for human approval */ userInterventionConfig?: UserInterventionConfig; + /** + * Webhook delivery method. + * - 'fetch': plain HTTP POST (default) + * - 'qstash': deliver via QStash publishJSON for guaranteed delivery + */ + webhookDelivery?: 'fetch' | 'qstash'; } /** @@ -144,14 +166,18 @@ export class AiAgentService { prompt, appContext, autoStart = true, + botContext, existingMessageIds = [], stepCallbacks, + stream, trigger, cronJobId, evalContext, maxSteps, userInterventionConfig, completionWebhook, + stepWebhook, + webhookDelivery, } = params; // Validate that either agentId or slug is provided @@ -184,8 +210,11 @@ export class AiAgentService { // 2. Handle topic creation: if no topicId provided, create a new topic; otherwise reuse existing let topicId = appContext?.topicId; if (!topicId) { - // Prepare metadata with cronJobId if provided - const metadata = cronJobId ? { cronJobId } : undefined; + // Prepare metadata with cronJobId and botContext if provided + const metadata = + cronJobId || botContext + ? { bot: botContext, cronJobId: cronJobId || undefined } + : undefined; const newTopic = await this.topicModel.create({ agentId: resolvedAgentId, @@ -491,14 +520,17 @@ export class AiAgentService { initialContext, initialMessages: allMessages, maxSteps, + stepWebhook, modelRuntimeConfig: { model, provider }, operationId, stepCallbacks, + stream, toolManifestMap, toolSourceMap, tools, userId: this.userId, userInterventionConfig, + webhookDelivery, }); log('execAgent: created operation %s (autoStarted: %s)', operationId, result.autoStarted); diff --git a/src/server/services/bot/AgentBridgeService.ts b/src/server/services/bot/AgentBridgeService.ts new file mode 100644 index 0000000000..df7e4524bd --- /dev/null +++ b/src/server/services/bot/AgentBridgeService.ts @@ -0,0 +1,445 @@ +import type { ChatTopicBotContext } from '@lobechat/types'; +import type { Message, SentMessage, Thread } from 'chat'; +import { emoji } from 'chat'; +import debug from 'debug'; +import urlJoin from 'url-join'; + +import { getServerDB } from '@/database/core/db-adaptor'; +import { appEnv } from '@/envs/app'; +import { AiAgentService } from '@/server/services/aiAgent'; +import { isQueueAgentRuntimeEnabled } from '@/server/services/queue/impls'; + +import { + renderError, + renderFinalReply, + renderStart, + renderStepProgress, + splitMessage, +} from './replyTemplate'; + +const log = debug('lobe-server:bot:agent-bridge'); + +const EXECUTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes + +// Status emoji added on receive, removed on complete +const RECEIVED_EMOJI = emoji.eyes; + +/** + * Extract a human-readable error message from agent runtime error objects. + * Handles various shapes: string, { message }, { errorType, error: { stack } }, etc. + */ +function extractErrorMessage(err: unknown): string { + if (!err) return 'Agent execution failed'; + if (typeof err === 'string') return err; + + const e = err as Record; + + // { message: '...' } + if (typeof e.message === 'string') return e.message; + + // { errorType: 'ProviderBizError', error: { stack: 'Error: ...\n at ...' } } + if (e.error?.stack) { + const firstLine = String(e.error.stack).split('\n')[0]; + const prefix = e.errorType ? `[${e.errorType}] ` : ''; + return `${prefix}${firstLine}`; + } + + // { body: { message: '...' } } + if (typeof e.body?.message === 'string') return e.body.message; + + return JSON.stringify(err); +} + +/** + * Fire-and-forget wrapper for reaction operations. + * Reactions should never block or fail the main flow. + */ +async function safeReaction(fn: () => Promise, label: string): Promise { + try { + await fn(); + } catch (error) { + log('safeReaction [%s] failed: %O', label, error); + } +} + +interface BridgeHandlerOpts { + agentId: string; + botContext?: ChatTopicBotContext; + userId: string; +} + +/** + * Platform-agnostic bridge between Chat SDK events and Agent Runtime. + * + * Uses in-process onComplete callback to get agent execution results. + * Provides real-time feedback via emoji reactions and editable progress messages. + */ +export class AgentBridgeService { + /** + * Handle a new @mention — start a fresh conversation. + */ + async handleMention( + thread: Thread<{ topicId?: string }>, + message: Message, + opts: BridgeHandlerOpts, + ): Promise { + const { agentId, botContext, userId } = opts; + + log('handleMention: agentId=%s, user=%s, text=%s', agentId, userId, message.text.slice(0, 80)); + + // Immediate feedback: mark as received + show typing + await safeReaction( + () => thread.adapter.addReaction(thread.id, message.id, RECEIVED_EMOJI), + 'add eyes', + ); + await thread.subscribe(); + await thread.startTyping(); + + try { + // executeWithCallback handles progress message (post + edit at each step) + // The final reply is edited into the progress message by onComplete + const { topicId } = await this.executeWithCallback(thread, message, { + agentId, + botContext, + trigger: 'bot', + userId, + }); + + // Persist topic mapping in thread state for follow-up messages + if (topicId) { + await thread.setState({ topicId }); + log('handleMention: stored topicId=%s in thread=%s state', topicId, thread.id); + } + } catch (error) { + log('handleMention error: %O', error); + const msg = error instanceof Error ? error.message : String(error); + await thread.post(`**Agent Execution Failed**\n\`\`\`\n${msg}\n\`\`\``); + } finally { + // Always clean up reactions + await this.removeReceivedReaction(thread, message); + } + } + + /** + * Handle a follow-up message inside a subscribed thread — multi-turn conversation. + */ + async handleSubscribedMessage( + thread: Thread<{ topicId?: string }>, + message: Message, + opts: BridgeHandlerOpts, + ): Promise { + const { agentId, botContext, userId } = opts; + const threadState = await thread.state; + const topicId = threadState?.topicId; + + log('handleSubscribedMessage: agentId=%s, thread=%s, topicId=%s', agentId, thread.id, topicId); + + if (!topicId) { + log('handleSubscribedMessage: no topicId in thread state, treating as new mention'); + return this.handleMention(thread, message, { agentId, botContext, userId }); + } + + // Immediate feedback: mark as received + show typing + await safeReaction( + () => thread.adapter.addReaction(thread.id, message.id, RECEIVED_EMOJI), + 'add eyes', + ); + await thread.startTyping(); + + try { + // executeWithCallback handles progress message (post + edit at each step) + await this.executeWithCallback(thread, message, { + agentId, + botContext, + topicId, + trigger: 'bot', + userId, + }); + } catch (error) { + log('handleSubscribedMessage error: %O', error); + const msg = error instanceof Error ? error.message : String(error); + await thread.post(`**Agent Execution Failed**. Details:\n\`\`\`\n${msg}\n\`\`\``); + } finally { + await this.removeReceivedReaction(thread, message); + } + } + + /** + * Dispatch to queue-mode webhooks or local in-memory callbacks based on runtime mode. + */ + private async executeWithCallback( + thread: Thread<{ topicId?: string }>, + userMessage: Message, + opts: { + agentId: string; + botContext?: ChatTopicBotContext; + topicId?: string; + trigger?: string; + userId: string; + }, + ): Promise<{ reply: string; topicId: string }> { + if (isQueueAgentRuntimeEnabled()) { + return this.executeWithWebhooks(thread, userMessage, opts); + } + return this.executeWithInMemoryCallbacks(thread, userMessage, opts); + } + + /** + * Queue mode: post initial message, configure step/completion webhooks, + * then return immediately. Progress updates and final reply are handled + * by the bot-callback webhook endpoint. + */ + private async executeWithWebhooks( + thread: Thread<{ topicId?: string }>, + userMessage: Message, + opts: { + agentId: string; + botContext?: ChatTopicBotContext; + topicId?: string; + trigger?: string; + userId: string; + }, + ): Promise<{ reply: string; topicId: string }> { + const { agentId, botContext, userId, topicId, trigger } = opts; + + const serverDB = await getServerDB(); + const aiAgentService = new AiAgentService(serverDB, userId); + + // Post initial progress message to get the message ID + let progressMessage: SentMessage | undefined; + try { + progressMessage = await thread.post(renderStart(userMessage.text)); + } catch (error) { + log('executeWithWebhooks: failed to post progress message: %O', error); + } + + const progressMessageId = progressMessage?.id; + if (!progressMessageId) { + throw new Error('Failed to post initial progress message'); + } + + // Build webhook URL for bot-callback endpoint + // Prefer INTERNAL_APP_URL for server-to-server calls (bypasses CDN/proxy) + const baseURL = appEnv.INTERNAL_APP_URL || appEnv.APP_URL; + if (!baseURL) { + throw new Error('APP_URL is required for queue mode bot webhooks'); + } + const callbackUrl = urlJoin(baseURL, '/api/agent/webhooks/bot-callback'); + + // Shared webhook body with bot context + const webhookBody = { + applicationId: botContext?.applicationId, + platformThreadId: botContext?.platformThreadId, + progressMessageId, + }; + + log( + 'executeWithWebhooks: agentId=%s, callbackUrl=%s, progressMessageId=%s', + agentId, + callbackUrl, + progressMessageId, + ); + + const result = await aiAgentService.execAgent({ + agentId, + appContext: topicId ? { topicId } : undefined, + autoStart: true, + botContext, + completionWebhook: { body: webhookBody, url: callbackUrl }, + prompt: userMessage.text, + stepWebhook: { body: webhookBody, url: callbackUrl }, + trigger, + userInterventionConfig: { approvalMode: 'headless' }, + webhookDelivery: 'qstash', + }); + + log( + 'executeWithWebhooks: operationId=%s, topicId=%s (webhook mode, returning immediately)', + result.operationId, + result.topicId, + ); + + // Return immediately — progress/completion handled by webhooks + return { reply: '', topicId: result.topicId }; + } + + /** + * Local mode: use in-memory step callbacks and wait for completion via Promise. + */ + private async executeWithInMemoryCallbacks( + thread: Thread<{ topicId?: string }>, + userMessage: Message, + opts: { + agentId: string; + botContext?: ChatTopicBotContext; + topicId?: string; + trigger?: string; + userId: string; + }, + ): Promise<{ reply: string; topicId: string }> { + const { agentId, botContext, userId, topicId, trigger } = opts; + + const serverDB = await getServerDB(); + const aiAgentService = new AiAgentService(serverDB, userId); + + // Post initial progress message + let progressMessage: SentMessage | undefined; + try { + progressMessage = await thread.post(renderStart(userMessage.text)); + } catch (error) { + log('executeWithInMemoryCallbacks: failed to post progress message: %O', error); + } + + // Track the last LLM content and tool calls for showing during tool execution + let lastLLMContent = ''; + let lastToolsCalling: + | Array<{ apiName: string; arguments?: string; identifier: string }> + | undefined; + let totalToolCalls = 0; + let operationStartTime = 0; + + return new Promise<{ reply: string; topicId: string }>((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Agent execution timed out`)); + }, EXECUTION_TIMEOUT); + + let assistantMessageId: string; + let resolvedTopicId: string; + + const getElapsedMs = () => (operationStartTime > 0 ? Date.now() - operationStartTime : 0); + + aiAgentService + .execAgent({ + agentId, + appContext: topicId ? { topicId } : undefined, + autoStart: true, + botContext, + prompt: userMessage.text, + stepCallbacks: { + onAfterStep: async (stepData) => { + const { content, shouldContinue, toolsCalling } = stepData; + if (!shouldContinue || !progressMessage) return; + + if (toolsCalling) totalToolCalls += toolsCalling.length; + + const progressText = renderStepProgress({ + ...stepData, + elapsedMs: getElapsedMs(), + lastContent: lastLLMContent, + lastToolsCalling, + totalToolCalls, + }); + + if (content) lastLLMContent = content; + if (toolsCalling) lastToolsCalling = toolsCalling; + + try { + progressMessage = await progressMessage.edit(progressText); + } catch (error) { + log('executeWithInMemoryCallbacks: failed to edit progress message: %O', error); + } + }, + + onComplete: async ({ finalState, reason }) => { + clearTimeout(timeout); + + log('onComplete: reason=%s, assistantMessageId=%s', reason, assistantMessageId); + + if (reason === 'error') { + const errorMsg = extractErrorMessage(finalState.error); + if (progressMessage) { + try { + await progressMessage.edit(renderError(errorMsg)); + } catch { + // ignore edit failure + } + } + reject(new Error(errorMsg)); + return; + } + + try { + // Extract reply from finalState.messages (accumulated across all steps) + const lastAssistantContent = finalState.messages + ?.slice() + .reverse() + .find( + (m: { content?: string; role: string }) => m.role === 'assistant' && m.content, + )?.content; + + if (lastAssistantContent) { + const finalText = renderFinalReply(lastAssistantContent, { + elapsedMs: getElapsedMs(), + llmCalls: finalState.usage?.llm?.apiCalls ?? 0, + toolCalls: finalState.usage?.tools?.totalCalls ?? 0, + totalCost: finalState.cost?.total ?? 0, + totalTokens: finalState.usage?.llm?.tokens?.total ?? 0, + }); + + const chunks = splitMessage(finalText); + + if (progressMessage) { + try { + await progressMessage.edit(chunks[0]); + // Post overflow chunks as follow-up messages + for (let i = 1; i < chunks.length; i++) { + await thread.post(chunks[i]); + } + } catch (error) { + log( + 'executeWithInMemoryCallbacks: failed to edit final progress message: %O', + error, + ); + } + } + + log( + 'executeWithInMemoryCallbacks: got response from finalState (%d chars, %d chunks)', + lastAssistantContent.length, + chunks.length, + ); + resolve({ reply: lastAssistantContent, topicId: resolvedTopicId }); + return; + } + + reject(new Error('Agent completed but no response content found')); + } catch (error) { + reject(error); + } + }, + }, + trigger, + userInterventionConfig: { approvalMode: 'headless' }, + }) + .then((result) => { + assistantMessageId = result.assistantMessageId; + resolvedTopicId = result.topicId; + operationStartTime = new Date(result.createdAt).getTime(); + + log( + 'executeWithInMemoryCallbacks: operationId=%s, assistantMessageId=%s, topicId=%s', + result.operationId, + result.assistantMessageId, + result.topicId, + ); + }) + .catch((error) => { + clearTimeout(timeout); + reject(error); + }); + }); + } + + /** + * Remove the received reaction from a user message (fire-and-forget). + */ + private async removeReceivedReaction( + thread: Thread<{ topicId?: string }>, + message: Message, + ): Promise { + await safeReaction( + () => thread.adapter.removeReaction(thread.id, message.id, RECEIVED_EMOJI), + 'remove eyes', + ); + } +} diff --git a/src/server/services/bot/BotMessageRouter.ts b/src/server/services/bot/BotMessageRouter.ts new file mode 100644 index 0000000000..ea6e9e96a1 --- /dev/null +++ b/src/server/services/bot/BotMessageRouter.ts @@ -0,0 +1,297 @@ +import { createDiscordAdapter } from '@chat-adapter/discord'; +import { createIoRedisState } from '@chat-adapter/state-ioredis'; +import { Chat, ConsoleLogger } from 'chat'; +import debug from 'debug'; + +import { getServerDB } from '@/database/core/db-adaptor'; +import { AgentBotProviderModel } from '@/database/models/agentBotProvider'; +import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis'; +import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt'; + +import { AgentBridgeService } from './AgentBridgeService'; + +const log = debug('lobe-server:bot:message-router'); + +interface ResolvedAgentInfo { + agentId: string; + userId: string; +} + +interface DiscordCredentials { + applicationId: string; + botToken: string; + publicKey: string; +} + +/** + * Routes incoming webhook events to the correct Chat SDK Bot instance + * and triggers message processing via AgentBridgeService. + */ +export class BotMessageRouter { + private bridge = new AgentBridgeService(); + + /** botToken → Chat instance (for webhook routing via x-discord-gateway-token) */ + private botInstancesByToken = new Map>(); + + /** applicationId → { agentId, userId } */ + private discordAgentMap = new Map(); + + /** Cached Chat instances keyed by applicationId */ + private botInstances = new Map>(); + + /** Store credentials for getDiscordBotConfigs() */ + private credentialsByAppId = new Map(); + + // ------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------ + + /** + * Get the webhook handler for a given platform. + * Returns a function compatible with Next.js Route Handler: `(req: Request) => Promise` + */ + getWebhookHandler(platform: string): (req: Request) => Promise { + return async (req: Request) => { + await this.ensureInitialized(); + + if (platform === 'discord') { + return this.handleDiscordWebhook(req); + } + + return new Response('No bot configured for this platform', { status: 404 }); + }; + } + + // ------------------------------------------------------------------ + // Discord webhook routing + // ------------------------------------------------------------------ + + private async handleDiscordWebhook(req: Request): Promise { + const bodyBuffer = await req.arrayBuffer(); + + log('handleDiscordWebhook: method=%s, content-length=%d', req.method, bodyBuffer.byteLength); + + // Check for forwarded Gateway event (from Gateway worker) + const gatewayToken = req.headers.get('x-discord-gateway-token'); + if (gatewayToken) { + log('Gateway forwarded event, token=%s...', gatewayToken.slice(0, 10)); + log( + 'Known tokens: %o', + [...this.botInstancesByToken.keys()].map((t) => t.slice(0, 10)), + ); + + // Log forwarded event details for debugging + try { + const bodyText = new TextDecoder().decode(bodyBuffer); + const event = JSON.parse(bodyText); + + if (event.type === 'GATEWAY_MESSAGE_CREATE') { + const d = event.data; + log( + 'MESSAGE_CREATE: author=%s (id=%s, bot=%s), mentions=%o, content=%s', + d?.author?.username, + d?.author?.id, + d?.author?.bot, + d?.mentions?.map((m: any) => ({ id: m.id, username: m.username })), + d?.content?.slice(0, 100), + ); + } + } catch { + // ignore parse errors + } + + const bot = this.botInstancesByToken.get(gatewayToken); + if (bot?.webhooks && 'discord' in bot.webhooks) { + log('Matched bot by token'); + return bot.webhooks.discord(this.cloneRequest(req, bodyBuffer)); + } + + log('No matching bot for gateway token'); + return new Response('No matching bot for gateway token', { status: 404 }); + } + + // HTTP Interactions — route by applicationId in the interaction payload + try { + const bodyText = new TextDecoder().decode(bodyBuffer); + const payload = JSON.parse(bodyText); + const appId = payload.application_id; + + if (appId) { + const bot = this.botInstances.get(appId); + if (bot?.webhooks && 'discord' in bot.webhooks) { + return bot.webhooks.discord(this.cloneRequest(req, bodyBuffer)); + } + } + } catch { + // Not valid JSON — fall through + } + + // Fallback: try all registered bots + for (const bot of this.botInstances.values()) { + if (bot.webhooks && 'discord' in bot.webhooks) { + try { + const resp = await bot.webhooks.discord(this.cloneRequest(req, bodyBuffer)); + if (resp.status !== 401) return resp; + } catch { + // signature mismatch — try next + } + } + } + + return new Response('No bot configured for Discord', { status: 404 }); + } + + private cloneRequest(req: Request, body: ArrayBuffer): Request { + return new Request(req.url, { + body, + headers: req.headers, + method: req.method, + }); + } + + // ------------------------------------------------------------------ + // Initialisation + // ------------------------------------------------------------------ + + private static REFRESH_INTERVAL_MS = 5 * 60_000; + + private initPromise: Promise | null = null; + private lastLoadedAt = 0; + private refreshPromise: Promise | null = null; + + private async ensureInitialized(): Promise { + if (!this.initPromise) { + this.initPromise = this.initialize(); + } + await this.initPromise; + + // Periodically refresh bot mappings in the background so newly added bots are discovered + if ( + Date.now() - this.lastLoadedAt > BotMessageRouter.REFRESH_INTERVAL_MS && + !this.refreshPromise + ) { + this.refreshPromise = this.loadAgentBots().finally(() => { + this.refreshPromise = null; + }); + } + } + + async initialize(): Promise { + log('Initializing BotMessageRouter'); + + await this.loadAgentBots(); + + log('Initialized: %d agent bots', this.botInstances.size); + } + + // ------------------------------------------------------------------ + // Per-agent bots from DB + // ------------------------------------------------------------------ + + private async loadAgentBots(): Promise { + try { + const serverDB = await getServerDB(); + const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); + + const providers = await AgentBotProviderModel.findEnabledByPlatform( + serverDB, + 'discord', + gateKeeper, + ); + + this.lastLoadedAt = Date.now(); + + log('Found %d Discord bot providers in DB', providers.length); + + for (const provider of providers) { + const { agentId, userId, applicationId, credentials } = provider; + const { botToken, publicKey } = credentials as any; + + if (this.botInstances.has(applicationId)) { + log('Skipping provider %s: already registered', applicationId); + continue; + } + + const adapters: Record = { + discord: createDiscordAdapter({ + applicationId, + botToken, + publicKey, + }), + }; + + const bot = this.createBot(adapters, `agent-${agentId}`); + this.registerHandlers(bot, { agentId, applicationId, platform: 'discord', userId }); + await bot.initialize(); + + this.botInstances.set(applicationId, bot); + this.botInstancesByToken.set(botToken, bot); + this.discordAgentMap.set(applicationId, { agentId, userId }); + this.credentialsByAppId.set(applicationId, { applicationId, botToken, publicKey }); + + log('Created Discord bot for agent=%s, appId=%s', agentId, applicationId); + } + } catch (error) { + log('Failed to load agent bots: %O', error); + } + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private createBot(adapters: Record, label: string): Chat { + const config: any = { + adapters, + userName: `lobehub-bot-${label}`, + }; + + const redisClient = getAgentRuntimeRedisClient(); + if (redisClient) { + config.state = createIoRedisState({ client: redisClient, logger: new ConsoleLogger() }); + } + + return new Chat(config); + } + + private registerHandlers( + bot: Chat, + info: ResolvedAgentInfo & { applicationId: string; platform: string }, + ): void { + const { agentId, applicationId, platform, userId } = info; + + bot.onNewMention(async (thread, message) => { + log('onNewMention: agent=%s, author=%s', agentId, message.author.userName); + await this.bridge.handleMention(thread, message, { + agentId, + botContext: { applicationId, platform, platformThreadId: thread.id }, + userId, + }); + }); + + bot.onSubscribedMessage(async (thread, message) => { + if (message.author.isBot === true) return; + + log('onSubscribedMessage: agent=%s, author=%s', agentId, message.author.userName); + + await this.bridge.handleSubscribedMessage(thread, message, { + agentId, + botContext: { applicationId, platform, platformThreadId: thread.id }, + userId, + }); + }); + } +} + +// ------------------------------------------------------------------ +// Singleton +// ------------------------------------------------------------------ + +let instance: BotMessageRouter | null = null; + +export function getBotMessageRouter(): BotMessageRouter { + if (!instance) { + instance = new BotMessageRouter(); + } + return instance; +} diff --git a/src/server/services/bot/__tests__/replyTemplate.test.ts b/src/server/services/bot/__tests__/replyTemplate.test.ts new file mode 100644 index 0000000000..518ede9e2c --- /dev/null +++ b/src/server/services/bot/__tests__/replyTemplate.test.ts @@ -0,0 +1,423 @@ +import { emoji } from 'chat'; +import { describe, expect, it } from 'vitest'; + +import type { RenderStepParams } from '../replyTemplate'; +import { + formatTokens, + renderError, + renderFinalReply, + renderLLMGenerating, + renderStart, + renderStepProgress, + renderToolExecuting, + splitMessage, + summarizeOutput, +} from '../replyTemplate'; + +// Helper to build a minimal RenderStepParams with defaults +function makeParams(overrides: Partial = {}): RenderStepParams { + return { + executionTimeMs: 0, + stepType: 'call_llm' as const, + thinking: true, + totalCost: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalSteps: 1, + totalTokens: 0, + ...overrides, + }; +} + +describe('replyTemplate', () => { + // ==================== renderStart ==================== + + describe('renderStart', () => { + it('should return a non-empty string', () => { + const result = renderStart(); + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + }); + }); + + // ==================== renderLLMGenerating ==================== + + describe('renderLLMGenerating', () => { + it('should show content + pending tool call with identifier|apiName and first arg only', () => { + expect( + renderLLMGenerating( + makeParams({ + content: 'Let me search for that.', + thinking: false, + toolsCalling: [ + { + apiName: 'web_search', + arguments: '{"query":"latest news","limit":10}', + identifier: 'builtin', + }, + ], + }), + ), + ).toBe('Let me search for that.\n\n○ **builtin·web_search**(query: "latest news")'); + }); + + it('should show multiple pending tool calls on separate lines with hollow circles', () => { + expect( + renderLLMGenerating( + makeParams({ + thinking: false, + toolsCalling: [ + { apiName: 'search', arguments: '{"q":"test"}', identifier: 'builtin' }, + { + apiName: 'readUrl', + arguments: '{"url":"https://example.com"}', + identifier: 'lobe-web-browsing', + }, + ], + }), + ), + ).toBe( + '○ **builtin·search**(q: "test")\n○ **lobe-web-browsing·readUrl**(url: "https://example.com")', + ); + }); + + it('should handle tool calls without args', () => { + expect( + renderLLMGenerating( + makeParams({ + thinking: false, + toolsCalling: [{ apiName: 'get_time', identifier: 'builtin' }], + }), + ), + ).toBe('○ **builtin·get_time**'); + }); + + it('should handle tool calls with invalid JSON args gracefully', () => { + expect( + renderLLMGenerating( + makeParams({ + thinking: false, + toolsCalling: [{ apiName: 'broken', arguments: 'not json', identifier: 'plugin' }], + }), + ), + ).toBe('○ **plugin·broken**'); + }); + + it('should omit identifier when empty', () => { + expect( + renderLLMGenerating( + makeParams({ + thinking: false, + toolsCalling: [{ apiName: 'search', arguments: '{"q":"test"}', identifier: '' }], + }), + ), + ).toBe('○ **search**(q: "test")'); + }); + + it('should fall back to lastContent when no content', () => { + expect( + renderLLMGenerating( + makeParams({ + lastContent: 'Previous response', + thinking: false, + toolsCalling: [{ apiName: 'search', identifier: 'builtin' }], + }), + ), + ).toBe('Previous response\n\n○ **builtin·search**'); + }); + + it('should show thinking when only reasoning present', () => { + expect( + renderLLMGenerating( + makeParams({ + reasoning: 'Let me think about this...', + thinking: false, + }), + ), + ).toBe(`${emoji.thinking} Let me think about this...`); + }); + + it('should show content with processing when pure text', () => { + expect( + renderLLMGenerating( + makeParams({ + content: 'Here is my response', + thinking: false, + }), + ), + ).toBe(`Here is my response\n\n`); + }); + + it('should show processing fallback when no content at all', () => { + expect(renderLLMGenerating(makeParams({ thinking: false }))).toBe( + `${emoji.thinking} Processing...`, + ); + }); + }); + + // ==================== renderToolExecuting ==================== + + describe('renderToolExecuting', () => { + it('should show completed tools with filled circle and result', () => { + expect( + renderToolExecuting( + makeParams({ + lastContent: 'I will search for that.', + lastToolsCalling: [ + { apiName: 'web_search', arguments: '{"query":"test"}', identifier: 'builtin' }, + ], + stepType: 'call_tool', + toolsResult: [ + { apiName: 'web_search', identifier: 'builtin', output: 'Found 3 results' }, + ], + }), + ), + ).toBe( + `I will search for that.\n\n⏺ **builtin·web_search**(query: "test")\n ⎿ Found 3 results\n\n${emoji.thinking} Processing...`, + ); + }); + + it('should show completed tools without result when output is empty', () => { + expect( + renderToolExecuting( + makeParams({ + lastToolsCalling: [{ apiName: 'get_time', identifier: 'builtin' }], + stepType: 'call_tool', + toolsResult: [{ apiName: 'get_time', identifier: 'builtin' }], + }), + ), + ).toBe(`⏺ **builtin·get_time**\n\n${emoji.thinking} Processing...`); + }); + + it('should show multiple completed tools with results', () => { + expect( + renderToolExecuting( + makeParams({ + lastToolsCalling: [ + { apiName: 'search', arguments: '{"q":"test"}', identifier: 'builtin' }, + { + apiName: 'readUrl', + arguments: '{"url":"https://example.com"}', + identifier: 'lobe-web-browsing', + }, + ], + stepType: 'call_tool', + toolsResult: [ + { apiName: 'search', identifier: 'builtin', output: 'Found 5 results' }, + { + apiName: 'readUrl', + identifier: 'lobe-web-browsing', + output: 'Page loaded successfully', + }, + ], + }), + ), + ).toBe( + `⏺ **builtin·search**(q: "test")\n ⎿ Found 5 results\n⏺ **lobe-web-browsing·readUrl**(url: "https://example.com")\n ⎿ Page loaded successfully\n\n${emoji.thinking} Processing...`, + ); + }); + + it('should show lastContent with processing when no lastToolsCalling', () => { + expect( + renderToolExecuting( + makeParams({ + lastContent: 'I found some results.', + stepType: 'call_tool', + }), + ), + ).toBe(`I found some results.\n\n${emoji.thinking} Processing...`); + }); + + it('should show processing fallback when no lastContent and no tools', () => { + expect(renderToolExecuting(makeParams({ stepType: 'call_tool' }))).toBe( + `${emoji.thinking} Processing...`, + ); + }); + }); + + // ==================== summarizeOutput ==================== + + describe('summarizeOutput', () => { + it('should return undefined for empty output', () => { + expect(summarizeOutput(undefined)).toBeUndefined(); + expect(summarizeOutput('')).toBeUndefined(); + }); + + it('should return first line for single-line output', () => { + expect(summarizeOutput('Hello world')).toBe('Hello world'); + }); + + it('should truncate long first line', () => { + const long = 'a'.repeat(120); + expect(summarizeOutput(long)).toBe('a'.repeat(100) + '...'); + }); + + it('should show line count for multi-line output', () => { + expect(summarizeOutput('line1\nline2\nline3')).toBe('line1 … +2 lines'); + }); + + it('should skip blank lines', () => { + expect(summarizeOutput('line1\n\n\nline2')).toBe('line1 … +1 lines'); + }); + }); + + // ==================== formatTokens ==================== + + describe('formatTokens', () => { + it('should return raw number for < 1000', () => { + expect(formatTokens(0)).toBe('0'); + expect(formatTokens(999)).toBe('999'); + }); + + it('should format thousands as k', () => { + expect(formatTokens(1000)).toBe('1.0k'); + expect(formatTokens(1234)).toBe('1.2k'); + expect(formatTokens(20_400)).toBe('20.4k'); + expect(formatTokens(999_999)).toBe('1000.0k'); + }); + + it('should format millions as m', () => { + expect(formatTokens(1_000_000)).toBe('1.0m'); + expect(formatTokens(1_234_567)).toBe('1.2m'); + expect(formatTokens(12_500_000)).toBe('12.5m'); + }); + }); + + // ==================== renderFinalReply ==================== + + describe('renderFinalReply', () => { + it('should append usage footer with tokens, cost, and call counts', () => { + expect( + renderFinalReply('Here is the answer.', { + llmCalls: 5, + toolCalls: 4, + totalCost: 0.0312, + totalTokens: 1234, + }), + ).toBe('Here is the answer.\n\n-# 1.2k tokens · $0.0312 | llm×5 | tools×4'); + }); + + it('should hide call counts when llmCalls=1 and toolCalls=0', () => { + expect( + renderFinalReply('Simple answer.', { + llmCalls: 1, + toolCalls: 0, + totalCost: 0.001, + totalTokens: 500, + }), + ).toBe('Simple answer.\n\n-# 500 tokens · $0.0010'); + }); + + it('should show call counts when toolCalls > 0 even if llmCalls=1', () => { + expect( + renderFinalReply('Answer.', { + llmCalls: 1, + toolCalls: 2, + totalCost: 0.005, + totalTokens: 800, + }), + ).toBe('Answer.\n\n-# 800 tokens · $0.0050 | llm×1 | tools×2'); + }); + + it('should show call counts when llmCalls > 1 even if toolCalls=0', () => { + expect( + renderFinalReply('Answer.', { + llmCalls: 3, + toolCalls: 0, + totalCost: 0.01, + totalTokens: 2000, + }), + ).toBe('Answer.\n\n-# 2.0k tokens · $0.0100 | llm×3 | tools×0'); + }); + + it('should hide call counts for zero usage', () => { + expect( + renderFinalReply('Done.', { llmCalls: 0, toolCalls: 0, totalCost: 0, totalTokens: 0 }), + ).toBe('Done.\n\n-# 0 tokens · $0.0000'); + }); + + it('should format large token counts', () => { + expect( + renderFinalReply('Result', { + llmCalls: 10, + toolCalls: 20, + totalCost: 1.5, + totalTokens: 1_234_567, + }), + ).toBe('Result\n\n-# 1.2m tokens · $1.5000 | llm×10 | tools×20'); + }); + }); + + // ==================== renderError ==================== + + describe('renderError', () => { + it('should wrap error in markdown code block', () => { + expect(renderError('Something went wrong')).toBe( + '**Agent Execution Failed**\n```\nSomething went wrong\n```', + ); + }); + }); + + // ==================== renderStepProgress (dispatcher) ==================== + + describe('renderStepProgress', () => { + it('should dispatch to renderLLMGenerating for call_llm with pending tools', () => { + expect( + renderStepProgress( + makeParams({ + content: 'Looking into it', + thinking: false, + toolsCalling: [{ apiName: 'search', arguments: '{"q":"test"}', identifier: 'builtin' }], + }), + ), + ).toBe('Looking into it\n\n○ **builtin·search**(q: "test")'); + }); + + it('should dispatch to renderToolExecuting for call_tool with completed tools', () => { + expect( + renderStepProgress( + makeParams({ + lastContent: 'Previous content', + lastToolsCalling: [ + { apiName: 'search', arguments: '{"q":"test"}', identifier: 'builtin' }, + ], + stepType: 'call_tool', + thinking: true, + toolsResult: [{ apiName: 'search', identifier: 'builtin', output: 'Found results' }], + }), + ), + ).toBe( + `Previous content\n\n⏺ **builtin·search**(q: "test")\n ⎿ Found results\n\n${emoji.thinking} Processing...`, + ); + }); + }); + + // ==================== splitMessage ==================== + + describe('splitMessage', () => { + it('should return single chunk for short text', () => { + expect(splitMessage('hello', 100)).toEqual(['hello']); + }); + + it('should split at paragraph boundary', () => { + const text = 'a'.repeat(80) + '\n\n' + 'b'.repeat(80); + expect(splitMessage(text, 100)).toEqual(['a'.repeat(80), 'b'.repeat(80)]); + }); + + it('should split at line boundary when no paragraph break fits', () => { + const text = 'a'.repeat(80) + '\n' + 'b'.repeat(80); + expect(splitMessage(text, 100)).toEqual(['a'.repeat(80), 'b'.repeat(80)]); + }); + + it('should hard-cut when no break found', () => { + const text = 'a'.repeat(250); + const chunks = splitMessage(text, 100); + expect(chunks).toEqual(['a'.repeat(100), 'a'.repeat(100), 'a'.repeat(50)]); + }); + + it('should handle multiple chunks', () => { + const text = 'chunk1\n\nchunk2\n\nchunk3'; + expect(splitMessage(text, 10)).toEqual(['chunk1', 'chunk2', 'chunk3']); + }); + }); +}); diff --git a/src/server/services/bot/ackPhrases/index.ts b/src/server/services/bot/ackPhrases/index.ts new file mode 100644 index 0000000000..d758dfa8b4 --- /dev/null +++ b/src/server/services/bot/ackPhrases/index.ts @@ -0,0 +1,162 @@ +import type { ContextType, TimeSegment } from './vibeMatrix'; +import { VIBE_CORPUS } from './vibeMatrix'; + +// Simple sample implementation to avoid dependency issues +function sample(arr: T[]): T | undefined { + if (!arr || arr.length === 0) return undefined; + return arr[Math.floor(Math.random() * arr.length)]; +} + +// ========================================== +// 3. 智能检测器 (The Brain) +// ========================================== + +/** + * 获取指定时区下的当前小时数 (0-23) + */ +function getLocalHour(date: Date, timeZone?: string): number { + if (!timeZone) return date.getHours(); + + try { + // 使用 Intl API 将时间格式化为指定时区的小时数 + const formatter = new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + hour12: false, + timeZone, + }); + const hourStr = formatter.format(date); + + // 处理可能的 '24' 这种边缘情况(极少见,但为了稳健) + const hour = parseInt(hourStr, 10); + return hour === 24 ? 0 : hour; + } catch (e) { + // 如果时区无效,回退到服务器时间 + console.warn(`[getExtremeAck] Invalid timezone: ${timeZone}, falling back to server time.`); + return date.getHours(); + } +} + +function getTimeSegment(hour: number): TimeSegment { + if (hour >= 5 && hour < 9) return 'early'; + if (hour >= 9 && hour < 12) return 'morning'; + if (hour >= 12 && hour < 14) return 'lunch'; + if (hour >= 14 && hour < 18) return 'afternoon'; + if (hour >= 18 && hour < 22) return 'evening'; + return 'night'; +} + +function getContextType(content: string): ContextType { + const lower = content.toLowerCase(); + + // 1. 🚨 Urgent (最高优先级) + if (/asap|urgent|emergency|!!!|quick|fast|hurry|立刻|马上|紧急/.test(lower)) { + return 'urgent'; + } + + // 2. 🐛 Debugging (特征明显) + if (/error|bug|fix|crash|fail|exception|undefined|null|报错|挂了|修复/.test(lower)) { + return 'debugging'; + } + + // 3. 💻 Coding (代码特征) + if ( + /const |import |function |=> |class |return |<\/|npm |git |docker|sudo|pip|api|json/.test(lower) + ) { + return 'coding'; + } + + // 4. 👀 Review (请求查看) + if (/review|check|look at|opinion|verify|audit|审查|看看|检查/.test(lower)) { + return 'review'; + } + + // 5. 📝 Planning (列表/计划) + if (/plan|todo|list|roadmap|schedule|summary|agenda|计划|安排|总结/.test(lower)) { + return 'planning'; + } + + // 6. 📚 Explanation (提问/教学) + if (/what is|how to|explain|guide|tutorial|teach|meaning|什么是|怎么做|解释/.test(lower)) { + return 'explanation'; + } + + // 7. 🎨 Creative (创作/设计) + if (/design|draft|write|idea|brainstorm|generate|create|image|logo|设计|文案|生成/.test(lower)) { + return 'creative'; + } + + // 8. 🧠 Analysis (兜底的长思考) + if ( + content.includes('?') || + content.length > 60 || + /analyze|compare|research|think|why|分析|研究/.test(lower) + ) { + return 'analysis'; + } + + // 9. 💬 Casual (短且非指令) + if (/hello|hi|hey|thanks|cool|wow|lol|哈哈|你好|谢谢/.test(lower)) { + return 'casual'; + } + + // 10. 👌 Quick (兜底) + return 'quick'; +} + +function humanizeText(text: string): string { + // 10% 的概率把首字母变成小写(显得随意) + if (Math.random() < 0.1) { + text = text.charAt(0).toLowerCase() + text.slice(1); + } + + // 10% 的概率去掉末尾标点 + if (Math.random() < 0.1 && text.endsWith('.')) { + text = text.slice(0, -1); + } + + return text; +} + +// ========================================== +// 4. 主入口 +// ========================================== + +export interface AckOptions { + /** + * 强制指定时间 (用于测试) + */ + date?: Date; + /** + * 用户所在的时区 (e.g. 'Asia/Shanghai', 'America/New_York') + * 如果不传,默认使用服务器时间 + */ + timezone?: string; +} + +export function getExtremeAck(content: string = '', options: AckOptions = {}): string { + const now = options.date || new Date(); + + // 计算用户当地时间的小时数 + const localHour = getLocalHour(now, options.timezone); + const timeSeg = getTimeSegment(localHour); + + const contextType = getContextType(content); + + // 筛选符合当前时间段和上下文的所有规则 + const candidates = VIBE_CORPUS.filter((rule) => { + // 检查时间匹配 + const timeMatch = rule.time === 'all' || rule.time.includes(timeSeg); + // 检查上下文匹配 + const contextMatch = rule.context === 'all' || rule.context.includes(contextType); + + return timeMatch && contextMatch; + }).flatMap((rule) => rule.phrases); + + // 如果没有匹配到任何规则,使用通用兜底 + if (candidates.length === 0) { + return 'Processing...'; + } + + const selected = sample(candidates) || 'Processing...'; + return humanizeText(selected); +} diff --git a/src/server/services/bot/ackPhrases/vibeMatrix.ts b/src/server/services/bot/ackPhrases/vibeMatrix.ts new file mode 100644 index 0000000000..ddf3cd4ddb --- /dev/null +++ b/src/server/services/bot/ackPhrases/vibeMatrix.ts @@ -0,0 +1,415 @@ +// ========================================== +// 1. 定义类型 +// ========================================== +export type TimeSegment = 'early' | 'morning' | 'lunch' | 'afternoon' | 'evening' | 'night'; +export type ContextType = + | 'urgent' // 最高优先级 + | 'debugging' // 错误修复 + | 'coding' // 代码实现 + | 'review' // 审查/检查 + | 'planning' // 计划/列表 + | 'analysis' // 深度思考 + | 'explanation' // 解释/教学 + | 'creative' // 创意/生成 + | 'casual' // 闲聊 + | 'quick'; // 兜底/短语 + +// 为了方便配置,定义 'all' 类型 +export type TimeRule = TimeSegment[] | 'all'; +export type ContextRule = ContextType[] | 'all'; + +export interface VibeRule { + context: ContextRule; + phrases: string[]; + time: TimeRule; +} + +// ========================================== +// 2. 语料规则库 (Rule-Based Corpus) +// ========================================== +export const VIBE_CORPUS: VibeRule[] = [ + // ================================================================= + // 🌍 GLOBAL / UNIVERSAL (通用回复) + // ================================================================= + { + phrases: [ + 'On it.', + 'Working on it.', + 'Processing.', + 'Copy that.', + 'Roger.', + 'Sure thing.', + 'One sec.', + 'Handling it.', + 'Checking.', + 'Got it.', + 'Standby.', + 'Will do.', + 'Affirmative.', + 'Looking into it.', + 'Give me a moment.', + ], + time: 'all', + context: ['quick', 'casual'], + }, + { + phrases: [ + '⚡ On it, ASAP!', + '🚀 Priority received.', + 'Handling this immediately.', + 'Rushing this.', + 'Fast tracking...', + 'Emergency mode engaged.', + 'Right away.', + 'Dropping everything else.', + 'Top priority.', + 'Moving fast.', + ], + time: 'all', + context: ['urgent'], + }, + { + phrases: [ + 'Compiling...', + 'Building...', + 'Refactoring...', + 'Optimizing logic...', + 'Pushing to memory...', + 'Executing...', + 'Running script...', + 'Analyzing stack...', + 'Implementing...', + 'Writing code...', + ], + time: 'all', + context: ['coding'], + }, + { + phrases: [ + '🐛 Debugging...', + 'Tracing the error...', + 'Checking logs...', + 'Hunting down the bug...', + 'Patching...', + 'Fixing...', + 'Analyzing crash dump...', + 'Squashing bugs...', + 'Investigating failure...', + 'Repairing...', + ], + time: 'all', + context: ['debugging'], + }, + { + phrases: [ + '🤔 Thinking...', + 'Processing context...', + 'Analyzing...', + 'Connecting the dots...', + 'Let me research that.', + 'Digging deeper...', + 'Investigating...', + 'Considering options...', + 'Evaluating...', + 'Deep dive...', + ], + time: 'all', + context: ['analysis', 'explanation', 'planning'], + }, + + // ================================================================= + // 🌅 EARLY MORNING (05:00 - 09:00) + // Vibe: Fresh, Coffee, Quiet, Start, Planning + // ================================================================= + { + phrases: [ + '☕️ Coffee first, then code.', + '🌅 Early bird mode.', + 'Fresh start.', + 'Morning sequence initiated.', + 'Waking up the neurons...', + 'Clear mind, clear code.', + 'Starting the day right.', + 'Loading morning resources...', + 'Rise and shine.', + 'Early morning processing...', + 'Good morning. On it.', + 'Booting up with the sun.', + 'Fresh perspective loading...', + 'Quiet morning logic.', + "Let's get ahead of the day.", + ], + time: ['early', 'morning'], + context: 'all', + }, + { + phrases: [ + '☕️ Caffeinating the bug...', + 'Squashing bugs with morning coffee.', + 'Fresh eyes on this error.', + 'Debugging before breakfast.', + 'Tracing logs while the coffee brews.', + 'Early fix incoming.', + ], + time: ['early', 'morning'], + context: ['debugging'], + }, + { + phrases: [ + '📝 Mapping out the day.', + 'Morning agenda...', + 'Planning the roadmap.', + "Setting up today's goals.", + 'Organizing tasks early.', + 'Structuring the day.', + ], + time: ['early', 'morning'], + context: ['planning'], + }, + + // ================================================================= + // ☀️ MORNING FLOW (09:00 - 12:00) + // Vibe: High Energy, Focus, Meetings, Execution + // ================================================================= + { + phrases: [ + '⚡ Full speed ahead.', + 'Morning sprint mode.', + "Let's crush this.", + 'Focusing...', + 'In the zone.', + 'Executing morning tasks.', + 'Productivity is high.', + 'Moving through the list.', + 'Active and running.', + 'Processing request.', + 'On the ball.', + "Let's get this done.", + 'Morning momentum.', + 'Handling it.', + 'Current status: Busy.', + ], + time: ['morning'], + context: 'all', + }, + { + phrases: [ + '🚀 Shipping updates.', + 'Pushing commits.', + 'Building fast.', + 'Code is flowing.', + 'Implementing feature.', + 'Writing logic.', + ], + time: ['morning'], + context: ['coding'], + }, + { + phrases: [ + '👀 Reviewing PRs.', + 'Morning code audit.', + 'Checking the specs.', + 'Verifying implementation.', + 'Scanning changes.', + ], + time: ['morning'], + context: ['review'], + }, + + // ================================================================= + // 🍱 LUNCH BREAK (12:00 - 14:00) + // Vibe: Food, Relax, Multitasking, Recharge + // ================================================================= + { + phrases: [ + '🥪 Lunchtime processing...', + 'Fueling up.', + 'Working through lunch.', + 'Bite sized update.', + 'Chewing on this...', + 'Lunch break vibes.', + 'Recharging batteries (and stomach).', + 'Mid-day pause.', + 'Processing while eating.', + 'Bon appétit to me.', + 'Taking a quick break, but checking.', + 'Food for thought...', + 'Lunch mode: Active.', + 'Halfway through the day.', + 'Refueling...', + ], + time: ['lunch'], + context: 'all', + }, + { + phrases: [ + '🐛 Hunting bugs on a full stomach.', + 'Debugging with a side of lunch.', + 'Squashing bugs between bites.', + 'Lunch debug session.', + 'Fixing this before the food coma.', + ], + time: ['lunch'], + context: ['debugging'], + }, + { + phrases: [ + '🎨 Napkin sketch ideas...', + 'Dreaming up concepts over lunch.', + 'Creative break.', + 'Brainstorming with food.', + 'Loose ideas flowing.', + ], + time: ['lunch'], + context: ['creative'], + }, + + // ================================================================= + // ☕️ AFTERNOON GRIND (14:00 - 18:00) + // Vibe: Coffee Refill, Push, Deadline, Focus + // ================================================================= + { + phrases: [ + '☕️ Afternoon refill.', + 'Powering through.', + 'Focus mode: ON.', + 'Afternoon sprint.', + 'Keeping the momentum.', + 'Second wind incoming.', + 'Grinding away.', + 'Locked in.', + 'Pushing to the finish line.', + 'Afternoon focus.', + 'Staying sharp.', + 'Caffeine levels critical... refilling.', + "Let's finish strong.", + 'Heads down, working.', + 'Processing...', + ], + time: ['afternoon'], + context: 'all', + }, + { + phrases: [ + '🚀 Shipping it.', + 'Crushing it before EOD.', + 'Final push.', + 'Deploying updates.', + 'Rushing the fix.', + 'Fast tracking this.', + ], + time: ['afternoon'], + context: ['coding', 'urgent'], + }, + { + phrases: [ + '🧠 Deep dive session.', + 'Analyzing the data...', + 'Thinking hard.', + 'Complex processing.', + 'Solving the puzzle.', + ], + time: ['afternoon'], + context: ['analysis'], + }, + + // ================================================================= + // 🌆 EVENING (18:00 - 22:00) + // Vibe: Winding Down, Review, Chill, Wrap Up + // ================================================================= + { + phrases: [ + '🌆 Winding down...', + 'Evening review.', + 'Wrapping up.', + 'Last tasks of the day.', + 'Evening vibes.', + 'Sunset processing.', + 'Closing tabs...', + 'Finishing up.', + 'One last thing.', + 'Checking before sign off.', + 'Evening mode.', + 'Relaxed processing.', + 'Tying up loose ends.', + 'End of day check.', + 'Almost done.', + ], + time: ['evening'], + context: 'all', + }, + { + phrases: [ + '👀 Final review.', + 'Evening code scan.', + "Checking the day's work.", + 'Verifying before sleep.', + 'Last look.', + ], + time: ['evening'], + context: ['review'], + }, + { + phrases: [ + '📝 Prepping for tomorrow.', + 'Evening recap.', + 'Summarizing the day.', + 'Planning ahead.', + 'Agenda for tomorrow.', + ], + time: ['evening'], + context: ['planning'], + }, + + // ================================================================= + // 🦉 LATE NIGHT (22:00 - 05:00) + // Vibe: Hacker, Silence, Flow, Deep Thought, Tired + // ================================================================= + { + phrases: [ + '🦉 Night owl mode.', + 'The world sleeps, we code.', + 'Midnight logic.', + 'Quietly processing...', + 'Dark mode enabled.', + 'Still here.', + 'Late night vibes.', + 'Burning the midnight oil.', + 'Silence and focus.', + 'You are still up?', + 'Night shift.', + 'Working in the dark.', + 'Insomnia mode.', + 'Processing...', + 'Watching the stars (and logs).', + ], + time: ['night'], + context: 'all', + }, + { + phrases: [ + '👾 Entering the matrix...', + 'Flow state.', + 'Just me and the terminal.', + 'Compiling in the dark...', + 'Hacking away.', + 'Midnight commit.', + 'Code never sleeps.', + 'System: Active.', + ], + time: ['night'], + context: ['coding', 'debugging'], + }, + { + phrases: [ + '🌌 Deep thought...', + 'Thinking in the silence...', + 'Analyzing the void...', + 'Late night clarity.', + 'Philosophical processing...', + 'Solving mysteries...', + ], + time: ['night'], + context: ['analysis', 'planning'], + }, +]; diff --git a/src/server/services/bot/discordRestApi.ts b/src/server/services/bot/discordRestApi.ts new file mode 100644 index 0000000000..f1f494a8c7 --- /dev/null +++ b/src/server/services/bot/discordRestApi.ts @@ -0,0 +1,27 @@ +import { REST } from '@discordjs/rest'; +import debug from 'debug'; +import { type RESTPostAPIChannelMessageResult, Routes } from 'discord-api-types/v10'; + +const log = debug('lobe-server:bot:discord-rest'); + +export class DiscordRestApi { + private readonly rest: REST; + + constructor(botToken: string) { + this.rest = new REST({ version: '10' }).setToken(botToken); + } + + async editMessage(channelId: string, messageId: string, content: string): Promise { + log('editMessage: channel=%s, message=%s', channelId, messageId); + await this.rest.patch(Routes.channelMessage(channelId, messageId), { body: { content } }); + } + + async createMessage(channelId: string, content: string): Promise<{ id: string }> { + log('createMessage: channel=%s', channelId); + const data = (await this.rest.post(Routes.channelMessages(channelId), { + body: { content }, + })) as RESTPostAPIChannelMessageResult; + + return { id: data.id }; + } +} diff --git a/src/server/services/bot/index.ts b/src/server/services/bot/index.ts new file mode 100644 index 0000000000..6861c55e89 --- /dev/null +++ b/src/server/services/bot/index.ts @@ -0,0 +1,5 @@ +export { AgentBridgeService } from './AgentBridgeService'; +export { BotMessageRouter, getBotMessageRouter } from './BotMessageRouter'; +export { platformBotRegistry } from './platforms'; +export { Discord, type DiscordBotConfig } from './platforms/discord'; +export type { PlatformBot, PlatformBotClass } from './types'; diff --git a/src/server/services/bot/platforms/discord.ts b/src/server/services/bot/platforms/discord.ts new file mode 100644 index 0000000000..115051d79a --- /dev/null +++ b/src/server/services/bot/platforms/discord.ts @@ -0,0 +1,110 @@ +import type { DiscordAdapter } from '@chat-adapter/discord'; +import { createDiscordAdapter } from '@chat-adapter/discord'; +import { createIoRedisState } from '@chat-adapter/state-ioredis'; +import { Chat, ConsoleLogger } from 'chat'; +import debug from 'debug'; + +import { appEnv } from '@/envs/app'; +import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis'; + +import type { PlatformBot } from '../types'; + +const log = debug('lobe-server:bot:gateway:discord'); + +const DEFAULT_DURATION_MS = 8 * 60 * 60 * 1000; // 8 hours + +export interface DiscordBotConfig { + [key: string]: string; + applicationId: string; + botToken: string; + publicKey: string; +} + +export interface GatewayListenerOptions { + durationMs?: number; + waitUntil?: (task: Promise) => void; +} + +export class Discord implements PlatformBot { + readonly platform = 'discord'; + readonly applicationId: string; + + private abort = new AbortController(); + private config: DiscordBotConfig; + private refreshTimer: ReturnType | null = null; + private stopped = false; + + constructor(config: DiscordBotConfig) { + this.config = config; + this.applicationId = config.applicationId; + } + + async start(options?: GatewayListenerOptions): Promise { + log('Starting DiscordBot appId=%s', this.applicationId); + + this.stopped = false; + this.abort = new AbortController(); + + const adapter = createDiscordAdapter({ + applicationId: this.config.applicationId, + botToken: this.config.botToken, + publicKey: this.config.publicKey, + }); + + const chatConfig: any = { + adapters: { discord: adapter }, + userName: `lobehub-gateway-${this.applicationId}`, + }; + + const redisClient = getAgentRuntimeRedisClient(); + if (redisClient) { + chatConfig.state = createIoRedisState({ client: redisClient, logger: new ConsoleLogger() }); + } + + const bot = new Chat(chatConfig); + + await bot.initialize(); + + const discordAdapter = (bot as any).adapters.get('discord') as DiscordAdapter; + const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS; + const waitUntil = options?.waitUntil ?? ((task: Promise) => task.catch(() => {})); + + const webhookUrl = `${appEnv.APP_URL}/api/agent/webhooks/discord`; + + await discordAdapter.startGatewayListener( + { waitUntil }, + durationMs, + this.abort.signal, + webhookUrl, + ); + + // Only schedule refresh timer in long-running mode (no custom options) + if (!options) { + this.refreshTimer = setTimeout(() => { + if (this.abort.signal.aborted || this.stopped) return; + + log( + 'DiscordBot appId=%s duration elapsed (%dh), refreshing...', + this.applicationId, + durationMs / 3_600_000, + ); + this.abort.abort(); + this.start().catch((err) => { + log('Failed to refresh DiscordBot appId=%s: %O', this.applicationId, err); + }); + }, durationMs); + } + + log('DiscordBot appId=%s started, webhookUrl=%s', this.applicationId, webhookUrl); + } + + async stop(): Promise { + log('Stopping DiscordBot appId=%s', this.applicationId); + this.stopped = true; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.abort.abort(); + } +} diff --git a/src/server/services/bot/platforms/index.ts b/src/server/services/bot/platforms/index.ts new file mode 100644 index 0000000000..040839e86a --- /dev/null +++ b/src/server/services/bot/platforms/index.ts @@ -0,0 +1,6 @@ +import type { PlatformBotClass } from '../types'; +import { Discord } from './discord'; + +export const platformBotRegistry: Record = { + discord: Discord, +}; diff --git a/src/server/services/bot/replyTemplate.ts b/src/server/services/bot/replyTemplate.ts new file mode 100644 index 0000000000..7933aad25d --- /dev/null +++ b/src/server/services/bot/replyTemplate.ts @@ -0,0 +1,269 @@ +import { emoji } from 'chat'; + +import type { StepPresentationData } from '../agentRuntime/types'; +import { getExtremeAck } from './ackPhrases'; + +// ==================== Message Splitting ==================== + +const DEFAULT_CHAR_LIMIT = 1800; + +export function splitMessage(text: string, limit = DEFAULT_CHAR_LIMIT): string[] { + if (text.length <= limit) return [text]; + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > 0) { + if (remaining.length <= limit) { + chunks.push(remaining); + break; + } + + // Try to find a paragraph break + let splitAt = remaining.lastIndexOf('\n\n', limit); + // Fall back to line break + if (splitAt <= 0) splitAt = remaining.lastIndexOf('\n', limit); + // Hard cut + if (splitAt <= 0) splitAt = limit; + + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).replace(/^\n+/, ''); + } + + return chunks; +} + +// ==================== Params ==================== + +type ToolCallItem = { apiName: string; arguments?: string; identifier: string }; +type ToolResultItem = { apiName: string; identifier: string; output?: string }; + +export interface RenderStepParams extends StepPresentationData { + elapsedMs?: number; + lastContent?: string; + lastToolsCalling?: ToolCallItem[]; + totalToolCalls?: number; +} + +// ==================== Helpers ==================== + +function formatToolName(tc: { apiName: string; identifier: string }): string { + if (tc.identifier) return `**${tc.identifier}·${tc.apiName}**`; + return `**${tc.apiName}**`; +} + +function formatToolCall(tc: ToolCallItem): string { + if (tc.arguments) { + try { + const args = JSON.parse(tc.arguments); + const entries = Object.entries(args); + if (entries.length > 0) { + const [k, v] = entries[0]; + return `${formatToolName(tc)}(${k}: ${JSON.stringify(v)})`; + } + } catch { + // invalid JSON, show name only + } + } + return formatToolName(tc); +} + +export function summarizeOutput(output: string | undefined, maxLength = 100): string | undefined { + if (!output) return undefined; + const lines = output.split('\n').filter((l) => l.trim()); + if (lines.length === 0) return undefined; + + const firstLine = lines[0].length > maxLength ? lines[0].slice(0, maxLength) + '...' : lines[0]; + + if (lines.length > 1) { + return `${firstLine} … +${lines.length - 1} lines`; + } + return firstLine; +} + +function formatPendingTools(toolsCalling: ToolCallItem[]): string { + return toolsCalling.map((tc) => `○ ${formatToolCall(tc)}`).join('\n'); +} + +function formatCompletedTools( + toolsCalling: ToolCallItem[], + toolsResult?: ToolResultItem[], +): string { + return toolsCalling + .map((tc, i) => { + const callStr = `⏺ ${formatToolCall(tc)}`; + const summary = summarizeOutput(toolsResult?.[i]?.output); + if (summary) { + return `${callStr}\n ⎿ ${summary}`; + } + return callStr; + }) + .join('\n'); +} + +export function formatTokens(tokens: number): string { + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`; + if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`; + return String(tokens); +} + +export function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes > 0) return `${minutes}m${seconds}s`; + return `${seconds}s`; +} + +function renderInlineStats(params: { + elapsedMs?: number; + totalCost: number; + totalTokens: number; + totalToolCalls?: number; +}): { footer: string; header: string } { + const { elapsedMs, totalToolCalls, totalTokens, totalCost } = params; + const time = elapsedMs && elapsedMs > 0 ? ` · ${formatDuration(elapsedMs)}` : ''; + + const header = + totalToolCalls && totalToolCalls > 0 + ? `> total **${totalToolCalls}** tools calling ${time}\n\n` + : ''; + + if (totalTokens <= 0) return { footer: '', header }; + + const footer = `\n\n-# ${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}`; + + return { footer, header }; +} + +// ==================== 1. Start ==================== + +export function renderStart(content?: string): string { + return getExtremeAck(content); +} + +// ==================== 2. LLM Generating ==================== + +/** + * LLM step just finished. Three sub-states: + * - has reasoning (thinking) + * - pure text content + * - has tool calls (about to execute tools) + */ +export function renderLLMGenerating(params: RenderStepParams): string { + const { + content, + elapsedMs, + lastContent, + reasoning, + toolsCalling, + totalCost, + totalTokens, + totalToolCalls, + } = params; + const displayContent = content || lastContent; + const { header, footer } = renderInlineStats({ + elapsedMs, + totalCost, + totalTokens, + totalToolCalls, + }); + + // Sub-state: LLM decided to call tools → show content + pending tool calls (○) + if (toolsCalling && toolsCalling.length > 0) { + const toolsList = formatPendingTools(toolsCalling); + + if (displayContent) return `${header}${displayContent}\n\n${toolsList}${footer}`; + return `${header}${toolsList}${footer}`; + } + + // Sub-state: has reasoning (thinking) + if (reasoning && !content) { + return `${header}${emoji.thinking} ${reasoning}${footer}`; + } + + // Sub-state: pure text content (waiting for next step) + if (displayContent) { + return `${header}${displayContent}\n\n${footer}`; + } + + return `${header}${emoji.thinking} Processing...${footer}`; +} + +// ==================== 3. Tool Executing ==================== + +/** + * Tool step just finished, LLM is next. + * Shows completed tools with results (⏺). + */ +export function renderToolExecuting(params: RenderStepParams): string { + const { + elapsedMs, + lastContent, + lastToolsCalling, + toolsResult, + totalCost, + totalTokens, + totalToolCalls, + } = params; + const { header, footer } = renderInlineStats({ + elapsedMs, + totalCost, + totalTokens, + totalToolCalls, + }); + + const parts: string[] = []; + + if (header) parts.push(header.trimEnd()); + + if (lastContent) parts.push(lastContent); + + if (lastToolsCalling && lastToolsCalling.length > 0) { + parts.push(formatCompletedTools(lastToolsCalling, toolsResult)); + parts.push(`${emoji.thinking} Processing...`); + } else { + parts.push(`${emoji.thinking} Processing...`); + } + + return parts.join('\n\n') + footer; +} + +// ==================== 4. Final Output ==================== + +export function renderFinalReply( + content: string, + params: { + elapsedMs?: number; + llmCalls: number; + toolCalls: number; + totalCost: number; + totalTokens: number; + }, +): string { + const { totalTokens, totalCost, llmCalls, toolCalls, elapsedMs } = params; + const time = elapsedMs && elapsedMs > 0 ? ` · ${formatDuration(elapsedMs)}` : ''; + const calls = llmCalls > 1 || toolCalls > 0 ? ` | llm×${llmCalls} | tools×${toolCalls}` : ''; + const footer = `-# ${formatTokens(totalTokens)} tokens · $${totalCost.toFixed(4)}${time}${calls}`; + return `${content}\n\n${footer}`; +} + +export function renderError(errorMessage: string): string { + return `**Agent Execution Failed**\n\`\`\`\n${errorMessage}\n\`\`\``; +} + +// ==================== Dispatcher ==================== + +/** + * Dispatch to the correct template based on step state. + */ +export function renderStepProgress(params: RenderStepParams): string { + if (params.stepType === 'call_llm') { + // LLM step finished → about to execute tools + return renderLLMGenerating(params); + } + + // Tool step finished → LLM is next + return renderToolExecuting(params); +} diff --git a/src/server/services/bot/types.ts b/src/server/services/bot/types.ts new file mode 100644 index 0000000000..2672e39465 --- /dev/null +++ b/src/server/services/bot/types.ts @@ -0,0 +1,8 @@ +export interface PlatformBot { + readonly applicationId: string; + readonly platform: string; + start: () => Promise; + stop: () => Promise; +} + +export type PlatformBotClass = new (config: any) => PlatformBot; diff --git a/src/server/services/gateway/GatewayManager.ts b/src/server/services/gateway/GatewayManager.ts new file mode 100644 index 0000000000..fb069f4285 --- /dev/null +++ b/src/server/services/gateway/GatewayManager.ts @@ -0,0 +1,212 @@ +import debug from 'debug'; + +import { getServerDB } from '@/database/core/db-adaptor'; +import { AgentBotProviderModel } from '@/database/models/agentBotProvider'; +import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt'; + +import type { PlatformBot, PlatformBotClass } from '../bot/types'; + +const log = debug('lobe-server:bot-gateway'); + +export interface GatewayManagerConfig { + registry: Record; +} + +export class GatewayManager { + private bots = new Map(); + private running = false; + private config: GatewayManagerConfig; + + constructor(config: GatewayManagerConfig) { + this.config = config; + } + + get isRunning(): boolean { + return this.running; + } + + // ------------------------------------------------------------------ + // Lifecycle (call once) + // ------------------------------------------------------------------ + + async start(): Promise { + if (this.running) { + log('GatewayManager already running, skipping'); + return; + } + + log('Starting GatewayManager'); + + await this.sync().catch((err) => { + console.error('[GatewayManager] Initial sync failed:', err); + }); + + this.running = true; + log('GatewayManager started with %d bots', this.bots.size); + } + + async stop(): Promise { + if (!this.running) return; + + log('Stopping GatewayManager'); + + for (const [key, bot] of this.bots) { + log('Stopping bot %s', key); + await bot.stop(); + } + this.bots.clear(); + + this.running = false; + log('GatewayManager stopped'); + } + + // ------------------------------------------------------------------ + // Bot operations (point-to-point) + // ------------------------------------------------------------------ + + async startBot(platform: string, applicationId: string, userId: string): Promise { + const key = `${platform}:${applicationId}`; + + // Stop existing if any + const existing = this.bots.get(key); + if (existing) { + log('Stopping existing bot %s before restart', key); + await existing.stop(); + this.bots.delete(key); + } + + // Load from DB (user-scoped, single row) + const serverDB = await getServerDB(); + const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); + const model = new AgentBotProviderModel(serverDB, userId, gateKeeper); + const provider = await model.findEnabledByApplicationId(platform, applicationId); + + if (!provider) { + log('No enabled provider found for %s', key); + return; + } + + const bot = this.createBot(platform, provider); + if (!bot) { + log('Unsupported platform: %s', platform); + return; + } + + await bot.start(); + this.bots.set(key, bot); + log('Started bot %s', key); + } + + async stopBot(platform: string, applicationId: string): Promise { + const key = `${platform}:${applicationId}`; + const bot = this.bots.get(key); + if (!bot) return; + + await bot.stop(); + this.bots.delete(key); + log('Stopped bot %s', key); + } + + // ------------------------------------------------------------------ + // DB sync + // ------------------------------------------------------------------ + + private async sync(): Promise { + for (const platform of Object.keys(this.config.registry)) { + try { + await this.syncPlatform(platform); + } catch (error) { + console.error('[GatewayManager] Sync error for %s:', platform, error); + } + } + } + + private async syncPlatform(platform: string): Promise { + const serverDB = await getServerDB(); + const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); + const providers = await AgentBotProviderModel.findEnabledByPlatform( + serverDB, + platform, + gateKeeper, + ); + + log('Sync: found %d enabled providers for %s', providers.length, platform); + + const activeKeys = new Set(); + + for (const provider of providers) { + const { applicationId, credentials } = provider; + const key = `${platform}:${applicationId}`; + activeKeys.add(key); + + log('Sync: processing provider %s, hasCredentials=%s', key, !!credentials); + + const existing = this.bots.get(key); + if (existing) { + log('Sync: bot %s already running, skipping', key); + continue; + } + + const bot = this.createBot(platform, provider); + if (!bot) { + log('Sync: createBot returned null for %s', key); + continue; + } + + try { + await bot.start(); + this.bots.set(key, bot); + log('Sync: started bot %s', key); + } catch (err) { + log('Sync: failed to start bot %s: %O', key, err); + } + } + + // Stop bots that are no longer in DB + for (const [key, bot] of this.bots) { + if (!key.startsWith(`${platform}:`)) continue; + if (activeKeys.has(key)) continue; + + log('Sync: bot %s removed from DB, stopping', key); + await bot.stop(); + this.bots.delete(key); + } + } + + // ------------------------------------------------------------------ + // Factory + // ------------------------------------------------------------------ + + private createBot( + platform: string, + provider: { applicationId: string; credentials: Record }, + ): PlatformBot | null { + const BotClass = this.config.registry[platform]; + if (!BotClass) { + log('No bot class registered for platform: %s', platform); + return null; + } + + return new BotClass({ + ...provider.credentials, + applicationId: provider.applicationId, + }); + } +} + +// ------------------------------------------------------------------ +// Singleton +// ------------------------------------------------------------------ + +const globalForGateway = globalThis as unknown as { gatewayManager?: GatewayManager }; + +export function getGatewayManager(): GatewayManager | undefined { + return globalForGateway.gatewayManager; +} + +export function createGatewayManager(config: GatewayManagerConfig): GatewayManager { + if (!globalForGateway.gatewayManager) { + globalForGateway.gatewayManager = new GatewayManager(config); + } + return globalForGateway.gatewayManager; +} diff --git a/src/server/services/gateway/botConnectQueue.ts b/src/server/services/gateway/botConnectQueue.ts new file mode 100644 index 0000000000..9a451e4f2a --- /dev/null +++ b/src/server/services/gateway/botConnectQueue.ts @@ -0,0 +1,87 @@ +import debug from 'debug'; +import type Redis from 'ioredis'; + +import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis'; + +const log = debug('lobe-server:bot:connect-queue'); + +const QUEUE_KEY = 'bot:gateway:connect_queue'; +const EXPIRE_MS = 10 * 60 * 1000; // 10 minutes + +interface ConnectEntry { + timestamp: number; + userId: string; +} + +export interface BotConnectItem { + applicationId: string; + platform: string; + userId: string; +} + +export class BotConnectQueue { + private get redis(): Redis | null { + return getAgentRuntimeRedisClient(); + } + + async push(platform: string, applicationId: string, userId: string): Promise { + if (!this.redis) { + throw new Error('Redis is not available, cannot enqueue bot connect request'); + } + + const field = `${platform}:${applicationId}`; + const value: ConnectEntry = { timestamp: Date.now(), userId }; + + await this.redis.hset(QUEUE_KEY, field, JSON.stringify(value)); + log('Pushed connect request: %s (userId=%s)', field, userId); + } + + async popAll(): Promise { + if (!this.redis) return []; + + const all = await this.redis.hgetall(QUEUE_KEY); + if (!all || Object.keys(all).length === 0) return []; + + const now = Date.now(); + const items: BotConnectItem[] = []; + const expiredFields: string[] = []; + + for (const [field, raw] of Object.entries(all)) { + try { + const entry: ConnectEntry = JSON.parse(raw); + + if (now - entry.timestamp > EXPIRE_MS) { + expiredFields.push(field); + continue; + } + + const separatorIdx = field.indexOf(':'); + if (separatorIdx === -1) continue; + + items.push({ + applicationId: field.slice(separatorIdx + 1), + platform: field.slice(0, separatorIdx), + userId: entry.userId, + }); + } catch { + expiredFields.push(field); + } + } + + if (expiredFields.length > 0) { + await this.redis.hdel(QUEUE_KEY, ...expiredFields); + log('Cleaned %d expired entries', expiredFields.length); + } + + log('Popped %d connect requests (%d expired)', items.length, expiredFields.length); + return items; + } + + async remove(platform: string, applicationId: string): Promise { + if (!this.redis) return; + + const field = `${platform}:${applicationId}`; + await this.redis.hdel(QUEUE_KEY, field); + log('Removed connect request: %s', field); + } +} diff --git a/src/server/services/gateway/index.ts b/src/server/services/gateway/index.ts new file mode 100644 index 0000000000..c24e887e78 --- /dev/null +++ b/src/server/services/gateway/index.ts @@ -0,0 +1,64 @@ +import debug from 'debug'; + +import { platformBotRegistry } from '../bot/platforms'; +import { BotConnectQueue } from './botConnectQueue'; +import { createGatewayManager, getGatewayManager } from './GatewayManager'; + +const log = debug('lobe-server:service:gateway'); + +const isVercel = !!process.env.VERCEL_ENV; + +export class GatewayService { + async ensureRunning(): Promise { + const existing = getGatewayManager(); + if (existing?.isRunning) { + log('GatewayManager already running'); + return; + } + + const manager = createGatewayManager({ registry: platformBotRegistry }); + await manager.start(); + + log('GatewayManager started'); + } + + async stop(): Promise { + const manager = getGatewayManager(); + if (!manager) return; + + await manager.stop(); + log('GatewayManager stopped'); + } + + async startBot( + platform: string, + applicationId: string, + userId: string, + ): Promise<'started' | 'queued'> { + if (isVercel) { + const queue = new BotConnectQueue(); + await queue.push(platform, applicationId, userId); + log('Queued bot connect %s:%s', platform, applicationId); + return 'queued'; + } + + let manager = getGatewayManager(); + if (!manager?.isRunning) { + log('GatewayManager not running, starting automatically...'); + await this.ensureRunning(); + manager = getGatewayManager(); + } + + await manager!.startBot(platform, applicationId, userId); + log('Started bot %s:%s', platform, applicationId); + return 'started'; + } + + async stopBot(platform: string, applicationId: string): Promise { + const manager = getGatewayManager(); + if (!manager?.isRunning) return; + + await manager.stopBot(platform, applicationId); + log('Stopped bot %s:%s', platform, applicationId); + } +} diff --git a/src/services/agentBotProvider.ts b/src/services/agentBotProvider.ts new file mode 100644 index 0000000000..274c9c0028 --- /dev/null +++ b/src/services/agentBotProvider.ts @@ -0,0 +1,39 @@ +import { lambdaClient } from '@/libs/trpc/client'; + +class AgentBotProviderService { + getByAgentId = async (agentId: string) => { + return lambdaClient.agentBotProvider.getByAgentId.query({ agentId }); + }; + + create = async (params: { + agentId: string; + applicationId: string; + credentials: Record; + enabled?: boolean; + platform: string; + }) => { + return lambdaClient.agentBotProvider.create.mutate(params); + }; + + update = async ( + id: string, + params: { + applicationId?: string; + credentials?: Record; + enabled?: boolean; + platform?: string; + }, + ) => { + return lambdaClient.agentBotProvider.update.mutate({ id, ...params }); + }; + + delete = async (id: string) => { + return lambdaClient.agentBotProvider.delete.mutate({ id }); + }; + + connectBot = async (params: { applicationId: string; platform: string }) => { + return lambdaClient.agentBotProvider.connectBot.mutate(params); + }; +} + +export const agentBotProviderService = new AgentBotProviderService(); diff --git a/src/services/chatGroup/index.ts b/src/services/chatGroup/index.ts index 313fcd7c0f..27e900c39e 100644 --- a/src/services/chatGroup/index.ts +++ b/src/services/chatGroup/index.ts @@ -1,4 +1,4 @@ -import { type AgentGroupDetail, type AgentItem } from '@lobechat/types'; +import { type AgentGroupDetail } from '@lobechat/types'; import { type ChatGroupAgentItem, @@ -69,7 +69,7 @@ class ChatGroupService { ...groupConfig, config: groupConfig.config as any, }, - members: members as Partial[], + members, supervisorConfig, }); }; @@ -113,7 +113,7 @@ class ChatGroupService { */ batchCreateAgentsInGroup = (groupId: string, agents: GroupMemberConfig[]) => { return lambdaClient.group.batchCreateAgentsInGroup.mutate({ - agents: agents as Partial[], + agents, groupId, }); }; diff --git a/src/spa/router/desktopRouter.config.tsx b/src/spa/router/desktopRouter.config.tsx index dd2fc35a36..db3620c970 100644 --- a/src/spa/router/desktopRouter.config.tsx +++ b/src/spa/router/desktopRouter.config.tsx @@ -38,6 +38,13 @@ export const desktopRoutes: RouteConfig[] = [ ), path: 'cron/:cronId', }, + { + element: dynamicElement( + () => import('@/routes/(main)/agent/integration'), + 'Desktop > Chat > Integration', + ), + path: 'integration', + }, ], element: dynamicLayout( () => import('@/routes/(main)/agent/_layout'), diff --git a/src/store/agent/slices/bot/action.ts b/src/store/agent/slices/bot/action.ts new file mode 100644 index 0000000000..f2522c61fd --- /dev/null +++ b/src/store/agent/slices/bot/action.ts @@ -0,0 +1,81 @@ +import { type SWRResponse } from 'swr'; + +import { mutate, useClientDataSWR } from '@/libs/swr'; +import { agentBotProviderService } from '@/services/agentBotProvider'; +import { type StoreSetter } from '@/store/types'; + +import { type AgentStore } from '../../store'; + +const FETCH_BOT_PROVIDERS_KEY = 'agentBotProviders'; + +export interface BotProviderItem { + applicationId: string; + credentials: Record; + enabled: boolean; + id: string; + platform: string; +} + +type Setter = StoreSetter; + +export const createBotSlice = (set: Setter, get: () => AgentStore, _api?: unknown) => + new BotSliceActionImpl(set, get, _api); + +export class BotSliceActionImpl { + readonly #get: () => AgentStore; + + constructor(set: Setter, get: () => AgentStore, _api?: unknown) { + void _api; + void set; + this.#get = get; + } + + createBotProvider = async (params: { + agentId: string; + applicationId: string; + credentials: Record; + platform: string; + }) => { + const result = await agentBotProviderService.create(params); + await this.internal_refreshBotProviders(params.agentId); + return result; + }; + + connectBot = async (params: { applicationId: string; platform: string }) => { + return agentBotProviderService.connectBot(params); + }; + + deleteBotProvider = async (id: string, agentId: string) => { + await agentBotProviderService.delete(id); + await this.internal_refreshBotProviders(agentId); + }; + + internal_refreshBotProviders = async (agentId?: string) => { + const id = agentId || this.#get().activeAgentId; + if (!id) return; + await mutate([FETCH_BOT_PROVIDERS_KEY, id]); + }; + + updateBotProvider = async ( + id: string, + agentId: string, + params: { + applicationId?: string; + credentials?: Record; + enabled?: boolean; + }, + ) => { + await agentBotProviderService.update(id, params); + await this.internal_refreshBotProviders(agentId); + }; + + useFetchBotProviders = (agentId?: string): SWRResponse => { + return useClientDataSWR( + agentId ? [FETCH_BOT_PROVIDERS_KEY, agentId] : null, + async ([, id]: [string, string]) => agentBotProviderService.getByAgentId(id), + { fallbackData: [], revalidateOnFocus: false }, + ); + }; +} + +export type BotSliceAction = Pick; diff --git a/src/store/agent/slices/bot/index.ts b/src/store/agent/slices/bot/index.ts new file mode 100644 index 0000000000..58d7a4daef --- /dev/null +++ b/src/store/agent/slices/bot/index.ts @@ -0,0 +1 @@ +export { type BotProviderItem, type BotSliceAction, createBotSlice } from './action'; diff --git a/src/store/agent/store.ts b/src/store/agent/store.ts index 572a23948a..885a4ac478 100644 --- a/src/store/agent/store.ts +++ b/src/store/agent/store.ts @@ -8,6 +8,8 @@ import { type AgentStoreState } from './initialState'; import { initialState } from './initialState'; import { type AgentSliceAction } from './slices/agent'; import { createAgentSlice } from './slices/agent'; +import { type BotSliceAction } from './slices/bot'; +import { createBotSlice } from './slices/bot'; import { type BuiltinAgentSliceAction } from './slices/builtin'; import { createBuiltinAgentSlice } from './slices/builtin'; import { type CronSliceAction } from './slices/cron'; @@ -22,6 +24,7 @@ import { createPluginSlice } from './slices/plugin'; export interface AgentStore extends AgentSliceAction, + BotSliceAction, BuiltinAgentSliceAction, CronSliceAction, KnowledgeSliceAction, @@ -29,6 +32,7 @@ export interface AgentStore AgentStoreState {} type AgentStoreAction = AgentSliceAction & + BotSliceAction & BuiltinAgentSliceAction & CronSliceAction & KnowledgeSliceAction & @@ -40,6 +44,7 @@ const createStore: StateCreator = ( ...initialState, ...flattenActions([ createAgentSlice(...parameters), + createBotSlice(...parameters), createBuiltinAgentSlice(...parameters), createCronSlice(...parameters), createKnowledgeSlice(...parameters), diff --git a/vercel.json b/vercel.json index 527d28c5bd..6231b5f2b6 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,11 @@ { "buildCommand": "bun run build", + "crons": [ + { + "path": "/api/agent/gateway/discord", + "schedule": "*/9 * * * *" + } + ], "installCommand": "npx pnpm@10.26.2 install", "rewrites": [ {