From ad086535e5d60dafa6d60cfc419aa3242eab20d8 Mon Sep 17 00:00:00 2001 From: akuuma <1919892171@qq.com> Date: Tue, 6 Jan 2026 15:32:20 +0800 Subject: [PATCH] feat(satori-bot): integrate Satori Protocol Adapter (#873) --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Hoshino-Yumetsuki Co-authored-by: Neko --- pnpm-lock.yaml | 117 ++++-- pnpm-workspace.yaml | 32 +- services/minecraft/package.json | 2 +- services/satori-bot/.env | 11 + services/satori-bot/README.md | 120 ++++++ services/satori-bot/data/.gitkeep | 1 + services/satori-bot/data/db.json | 4 + services/satori-bot/package.json | 41 ++ .../src/actions/read-unread-messages.ts | 52 +++ .../satori-bot/src/actions/send-message.ts | 50 +++ services/satori-bot/src/bot/index.ts | 363 ++++++++++++++++++ services/satori-bot/src/client/satori-api.ts | 95 +++++ .../satori-bot/src/client/satori-client.ts | 284 ++++++++++++++ services/satori-bot/src/db/index.ts | 90 +++++ services/satori-bot/src/index.ts | 100 +++++ services/satori-bot/src/llm/actions.ts | 129 +++++++ services/satori-bot/src/prompts/index.ts | 9 + .../src/prompts/personality-v1.velin.md | 9 + .../src/prompts/system-action-gen-v1.velin.md | 54 +++ services/satori-bot/src/types/bot.ts | 70 ++++ services/satori-bot/src/types/satori.ts | 160 ++++++++ services/satori-bot/src/utils/path.ts | 6 + services/satori-bot/src/utils/promise.ts | 18 + services/satori-bot/src/utils/velin.ts | 20 + services/satori-bot/tsconfig.json | 21 + 25 files changed, 1810 insertions(+), 48 deletions(-) create mode 100644 services/satori-bot/.env create mode 100644 services/satori-bot/README.md create mode 100644 services/satori-bot/data/.gitkeep create mode 100644 services/satori-bot/data/db.json create mode 100644 services/satori-bot/package.json create mode 100644 services/satori-bot/src/actions/read-unread-messages.ts create mode 100644 services/satori-bot/src/actions/send-message.ts create mode 100644 services/satori-bot/src/bot/index.ts create mode 100644 services/satori-bot/src/client/satori-api.ts create mode 100644 services/satori-bot/src/client/satori-client.ts create mode 100644 services/satori-bot/src/db/index.ts create mode 100644 services/satori-bot/src/index.ts create mode 100644 services/satori-bot/src/llm/actions.ts create mode 100644 services/satori-bot/src/prompts/index.ts create mode 100644 services/satori-bot/src/prompts/personality-v1.velin.md create mode 100644 services/satori-bot/src/prompts/system-action-gen-v1.velin.md create mode 100644 services/satori-bot/src/types/bot.ts create mode 100644 services/satori-bot/src/types/satori.ts create mode 100644 services/satori-bot/src/utils/path.ts create mode 100644 services/satori-bot/src/utils/promise.ts create mode 100644 services/satori-bot/src/utils/velin.ts create mode 100644 services/satori-bot/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e36760923..125133ef5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2817,7 +2817,7 @@ importers: version: 66.5.11 '@wxt-dev/module-vue': specifier: ^1.0.3 - version: 1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vue-tsc: specifier: ^3.1.8 version: 3.2.1(typescript@5.9.3) @@ -2897,8 +2897,8 @@ importers: specifier: ^5.0.1 version: 5.0.1 minecraft-data: - specifier: ^3.102.3 - version: 3.102.3 + specifier: ^3.101.0 + version: 3.101.0 mineflayer: specifier: ^4.33.0 version: 4.33.0(encoding@0.1.13) @@ -2954,6 +2954,55 @@ importers: specifier: ^3.25.1 version: 3.25.1(zod@4.2.1) + services/satori-bot: + dependencies: + '@guiiai/logg': + specifier: 'catalog:' + version: 1.2.11 + '@moeru/std': + specifier: 'catalog:' + version: 0.1.0-beta.14 + '@velin-dev/core': + specifier: ^0.3.4 + version: 0.3.4(typescript@5.9.3) + '@xsai/generate-text': + specifier: 'catalog:' + version: 0.4.0-beta.13 + '@xsai/shared-chat': + specifier: 'catalog:' + version: 0.4.0-beta.13 + '@xsai/utils-chat': + specifier: 'catalog:' + version: 0.4.0-beta.13 + best-effort-json-parser: + specifier: ^1.2.1 + version: 1.2.1 + es-toolkit: + specifier: ^1.43.0 + version: 1.43.0 + lowdb: + specifier: ^7.0.1 + version: 7.0.1 + nanoid: + specifier: ^5.1.6 + version: 5.1.6 + ws: + specifier: ^8.18.0 + version: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.19.3 + '@types/ws': + specifier: ^8.5.13 + version: 8.18.1 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + services/telegram-bot: dependencies: '@dotenvx/dotenvx': @@ -12442,6 +12491,10 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lowdb@7.0.1: + resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==} + engines: {node: '>=18'} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -12787,8 +12840,8 @@ packages: minecraft-data@2.221.0: resolution: {integrity: sha512-0AhqzbIKb6WqPSF6qBevaPryeWOz545hLxt6q+gfJF8YIQX/YfkyX/nXWhl+pSIS2rTBcQ0RJkRCtTeRzQwHDA==} - minecraft-data@3.102.3: - resolution: {integrity: sha512-JxUPTUlamQ04GSK7YI3657BRurHLBRAhsmPb4gajd4z/p6t3LJh4l0HWIyNHRpJbQiUCIjn+mhos5oB+yqH0mQ==} + minecraft-data@3.101.0: + resolution: {integrity: sha512-9kD2sbI9BvjQtN/ZlMkCdgaLJIHPXDGruMEJ0DOO29RB9sVDmSBLjDkH9PyRmlAye/WZlqk/1/b54vWq6ObzxQ==} minecraft-folder-path@1.2.0: resolution: {integrity: sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==} @@ -14612,6 +14665,10 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + steno@4.0.2: + resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==} + engines: {node: '>=18'} + store2@2.14.4: resolution: {integrity: sha512-srTItn1GOvyvOycgxjAnPA63FZNwy0PTyUBFMHRM+hVFltAeoh0LmNBz9SZqUS9mMqGk8rfyWyXn3GH5ReJ8Zw==} @@ -21969,12 +22026,6 @@ snapshots: vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vue: 3.5.25(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))': - dependencies: - '@rolldown/pluginutils': 1.0.0-beta.53 - vite: 8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vue: 3.5.25(typescript@5.9.3) - '@vitest/browser-playwright@4.0.16(bufferutil@4.1.0)(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)': dependencies: '@vitest/browser': 4.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) @@ -22720,9 +22771,9 @@ snapshots: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 - '@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@wxt-dev/module-vue@1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@vitejs/plugin-vue': 6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) wxt: 0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - vite @@ -26726,6 +26777,10 @@ snapshots: longest-streak@3.1.0: {} + lowdb@7.0.1: + dependencies: + steno: 4.0.2 + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -27254,7 +27309,7 @@ snapshots: minecraft-data@2.221.0: {} - minecraft-data@3.102.3: {} + minecraft-data@3.101.0: {} minecraft-folder-path@1.2.0: {} @@ -27267,7 +27322,7 @@ snapshots: debug: 4.4.3 endian-toggle: 0.0.0 lodash.merge: 4.6.2 - minecraft-data: 3.102.3 + minecraft-data: 3.101.0 minecraft-folder-path: 1.2.0 node-fetch: 2.7.0(encoding@0.1.13) node-rsa: 0.4.2 @@ -27285,7 +27340,7 @@ snapshots: mineflayer-armor-manager@2.0.1(mineflayer@4.33.0(encoding@0.1.13)): dependencies: - minecraft-data: 3.102.3 + minecraft-data: 3.101.0 mineflayer: 4.33.0(encoding@0.1.13) mineflayer-auto-eat@5.0.3(encoding@0.1.13): @@ -27307,7 +27362,7 @@ snapshots: mineflayer-pathfinder@2.4.5: dependencies: - minecraft-data: 3.102.3 + minecraft-data: 3.101.0 prismarine-block: 1.22.0 prismarine-entity: 2.5.0 prismarine-item: 1.17.0 @@ -27370,12 +27425,12 @@ snapshots: mineflayer@4.33.0(encoding@0.1.13): dependencies: - minecraft-data: 3.102.3 + minecraft-data: 3.101.0 minecraft-protocol: 1.62.0(encoding@0.1.13) - prismarine-biome: 1.3.0(minecraft-data@3.102.3)(prismarine-registry@1.11.0) + prismarine-biome: 1.3.0(minecraft-data@3.101.0)(prismarine-registry@1.11.0) prismarine-block: 1.22.0 prismarine-chat: 1.12.0 - prismarine-chunk: 1.39.0(minecraft-data@3.102.3) + prismarine-chunk: 1.39.0(minecraft-data@3.101.0) prismarine-entity: 2.5.0 prismarine-item: 1.17.0 prismarine-nbt: 2.8.0 @@ -28432,15 +28487,15 @@ snapshots: minecraft-data: 2.221.0 prismarine-registry: 1.11.0 - prismarine-biome@1.3.0(minecraft-data@3.102.3)(prismarine-registry@1.11.0): + prismarine-biome@1.3.0(minecraft-data@3.101.0)(prismarine-registry@1.11.0): dependencies: - minecraft-data: 3.102.3 + minecraft-data: 3.101.0 prismarine-registry: 1.11.0 prismarine-block@1.22.0: dependencies: - minecraft-data: 3.102.3 - prismarine-biome: 1.3.0(minecraft-data@3.102.3)(prismarine-registry@1.11.0) + minecraft-data: 3.101.0 + prismarine-biome: 1.3.0(minecraft-data@3.101.0)(prismarine-registry@1.11.0) prismarine-chat: 1.12.0 prismarine-item: 1.17.0 prismarine-nbt: 2.8.0 @@ -28465,9 +28520,9 @@ snapshots: transitivePeerDependencies: - minecraft-data - prismarine-chunk@1.39.0(minecraft-data@3.102.3): + prismarine-chunk@1.39.0(minecraft-data@3.101.0): dependencies: - prismarine-biome: 1.3.0(minecraft-data@3.102.3)(prismarine-registry@1.11.0) + prismarine-biome: 1.3.0(minecraft-data@3.101.0)(prismarine-registry@1.11.0) prismarine-block: 1.22.0 prismarine-nbt: 2.8.0 prismarine-registry: 1.11.0 @@ -28500,7 +28555,7 @@ snapshots: prismarine-physics@1.10.0: dependencies: - minecraft-data: 3.102.3 + minecraft-data: 3.101.0 prismarine-nbt: 2.8.0 vec3: 0.1.10 @@ -28518,7 +28573,7 @@ snapshots: prismarine-registry@1.11.0: dependencies: - minecraft-data: 3.102.3 + minecraft-data: 3.101.0 prismarine-block: 1.22.0 prismarine-nbt: 2.8.0 @@ -28527,9 +28582,9 @@ snapshots: '@tweenjs/tween.js': 23.1.3 compression: 1.8.1 express: 4.22.1 - minecraft-data: 3.102.3 + minecraft-data: 3.101.0 prismarine-block: 1.22.0 - prismarine-chunk: 1.39.0(minecraft-data@3.102.3) + prismarine-chunk: 1.39.0(minecraft-data@3.101.0) prismarine-world: 3.6.3 socket.io: 4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) socket.io-client: 4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) @@ -29567,6 +29622,8 @@ snapshots: stdin-discarder@0.2.2: {} + steno@4.0.2: {} + store2@2.14.4: {} streamx@2.23.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 309305101..08d920624 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ +catalogMode: prefer + +shellEmulator: true packages: - packages/** - plugins/** @@ -7,6 +10,18 @@ packages: - apps/** - '!**/dist/**' +overrides: + array-flatten: npm:@nolyfill/array-flatten@^1.0.44 + axios: npm:feaxios@^0.0.23 + is-core-module: npm:@nolyfill/is-core-module@^1.0.39 + isarray: npm:@nolyfill/isarray@^1.0.44 + safe-buffer: npm:@nolyfill/safe-buffer@^1.0.44 + safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44 + side-channel: npm:@nolyfill/side-channel@^1.0.44 + string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@^1.0.44 + +patchedDependencies: + srvx@0.9.8: patches/srvx@0.9.8.patch catalog: '@capacitor/cli': ^8.0.0 '@capacitor/core': ^8.0.0 @@ -57,8 +72,6 @@ catalog: xsschema: 0.4.0-beta.13 zod: ^4.2.1 -catalogMode: prefer - catalogs: rolldown-vite: vite: npm:rolldown-vite@^7.2.11 @@ -94,18 +107,3 @@ onlyBuiltDependencies: - spawn-sync - utf-8-validate - vue-demi - -overrides: - array-flatten: npm:@nolyfill/array-flatten@^1.0.44 - axios: npm:feaxios@^0.0.23 - is-core-module: npm:@nolyfill/is-core-module@^1.0.39 - isarray: npm:@nolyfill/isarray@^1.0.44 - safe-buffer: npm:@nolyfill/safe-buffer@^1.0.44 - safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44 - side-channel: npm:@nolyfill/side-channel@^1.0.44 - string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@^1.0.44 - -patchedDependencies: - srvx@0.9.8: patches/srvx@0.9.8.patch - -shellEmulator: true diff --git a/services/minecraft/package.json b/services/minecraft/package.json index bb68febd1..2c4464546 100644 --- a/services/minecraft/package.json +++ b/services/minecraft/package.json @@ -18,7 +18,7 @@ "awilix": "^12.0.5", "es-toolkit": "^1.43.0", "eventemitter3": "^5.0.1", - "minecraft-data": "^3.102.3", + "minecraft-data": "^3.101.0", "mineflayer": "^4.33.0", "mineflayer-armor-manager": "^2.0.1", "mineflayer-auto-eat": "^5.0.3", diff --git a/services/satori-bot/.env b/services/satori-bot/.env new file mode 100644 index 000000000..2a3f0b5e0 --- /dev/null +++ b/services/satori-bot/.env @@ -0,0 +1,11 @@ +# Satori Configuration +SATORI_WS_URL=ws://localhost:5140/satori/v1/events +SATORI_API_BASE_URL=http://localhost:5140/satori/v1 +SATORI_TOKEN=your_satori_token_here + +# LLM Configuration +LLM_API_KEY=your_api_key_here +LLM_API_BASE_URL=https://api.openai.com/v1 +LLM_MODEL=gpt-4 +LLM_RESPONSE_LANGUAGE=English +LLM_OLLAMA_DISABLE_THINK=false diff --git a/services/satori-bot/README.md b/services/satori-bot/README.md new file mode 100644 index 000000000..b3de8d4d6 --- /dev/null +++ b/services/satori-bot/README.md @@ -0,0 +1,120 @@ +# AIRI Satori Bot + +一个基于 Satori 协议的 AI 聊天机器人,可以通过 Koishi 连接到多个聊天平台(QQ、Telegram、Discord、飞书等)。 + +## 架构说明 + +本项目采用**独立架构**,参考了 Telegram Bot 的实现模式 + +## 前置要求 + +1. **Koishi 实例**:需要一个运行中的 Koishi 实例,并启用 Satori 服务 +2. **LLM API**:OpenAI API 或兼容的 API(如 Ollama、vLLM 等) +3. **Node.js**: >= 18.0.0 +4. **pnpm**: >= 8.0.0 + +## 安装 + +```bash +# 在项目根目录 +pnpm install +``` + +## 配置 + +复制 `.env` 文件并修改配置: + +```bash +# 在 services/satori-bot 目录 +cp .env .env.local +``` + +编辑 `.env.local`: + +```env +# Satori Configuration +SATORI_WS_URL=ws://localhost:5140/satori/v1/events +SATORI_API_BASE_URL=http://localhost:5140/satori/v1 +SATORI_TOKEN=your_satori_token_here + +# LLM Configuration +LLM_API_KEY=your_api_key_here +LLM_API_BASE_URL=https://api.openai.com/v1 +LLM_MODEL=gpt-4 +LLM_RESPONSE_LANGUAGE=简体中文 +LLM_OLLAMA_DISABLE_THINK=false +``` + +### 配置说明 + +#### Satori 配置 + +- `SATORI_WS_URL`: Satori WebSocket 地址(Koishi 默认:`ws://localhost:5140/satori/v1/events`) +- `SATORI_API_BASE_URL`: Satori HTTP API 地址(Koishi 默认:`http://localhost:5140/satori/v1`) +- `SATORI_TOKEN`: Satori 认证令牌(在 Koishi 配置中获取,如果为空 请留空,如:`SATORI_TOKEN=`) + +**重要**: Koishi 的 Satori 服务默认路由是 `/satori/v1`,因此完整的 API 路径会自动拼接,例如: +- 发送消息: `http://localhost:5140/satori/v1/message.create` +- 获取消息: `http://localhost:5140/satori/v1/message.get` + +#### LLM 配置 + +- `LLM_API_KEY`: LLM API 密钥 +- `LLM_API_BASE_URL`: LLM API 地址 +- `LLM_MODEL`: 使用的模型名称 +- `LLM_RESPONSE_LANGUAGE`: 回复语言(默认:简体中文) +- `LLM_OLLAMA_DISABLE_THINK`: 是否禁用 Ollama 的思考模式 + +## 使用 + +### 开发模式 + +```bash +# 在项目根目录 +pnpm --filter @proj-airi/satori-bot dev +``` + +### 生产模式 + +```bash +# 在项目根目录 +pnpm --filter @proj-airi/satori-bot start +``` + +### 类型检查 + +```bash +# 在项目根目录 +pnpm --filter @proj-airi/satori-bot typecheck +``` + +## 常见问题 + +### 1. 如何配置 Koishi? + +在 Koishi 中启用 `server-satori`,配置项保持默认即可,无需改动。 + +### 2. 如何自定义 AI 人格? + +可以编辑以下文件: + +- `services\satori-bot\src\prompts\personality-v1.velin.md` +- `services\satori-bot\src\prompts\system-action-gen-v1.velin.md` + +### 3. 数据库文件在哪里? + +`services/satori-bot/data/db.json` + +## 贡献 + +欢迎提交 Issue 和 Pull Request! + +## 许可证 + +MIT License + +## 相关链接 + +- [AIRI 项目](https://github.com/moeru-ai/airi) +- [Satori 协议文档](https://satori.chat/) +- [Koishi 文档](https://koishi.chat/) diff --git a/services/satori-bot/data/.gitkeep b/services/satori-bot/data/.gitkeep new file mode 100644 index 000000000..8d1c8b69c --- /dev/null +++ b/services/satori-bot/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/services/satori-bot/data/db.json b/services/satori-bot/data/db.json new file mode 100644 index 000000000..2a1e6f8b7 --- /dev/null +++ b/services/satori-bot/data/db.json @@ -0,0 +1,4 @@ +{ + "channels": [], + "messages": [] +} diff --git a/services/satori-bot/package.json b/services/satori-bot/package.json new file mode 100644 index 000000000..7d6ae64c9 --- /dev/null +++ b/services/satori-bot/package.json @@ -0,0 +1,41 @@ +{ + "name": "@proj-airi/satori-bot", + "type": "module", + "private": true, + "description": "Satori protocol adapter for AIRI - Connect to multiple chat platforms via Satori", + "author": { + "name": "Moeru AI Project AIRI Team", + "email": "airi@moeru.ai", + "url": "https://github.com/moeru-ai" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/moeru-ai/airi.git", + "directory": "services/satori-bot" + }, + "scripts": { + "start": "tsx --env-file=.env --env-file-if-exists=.env.local src/index.ts", + "dev": "tsx watch --env-file=.env --env-file-if-exists=.env.local src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@guiiai/logg": "catalog:", + "@moeru/std": "catalog:", + "@velin-dev/core": "^0.3.4", + "@xsai/generate-text": "catalog:", + "@xsai/shared-chat": "catalog:", + "@xsai/utils-chat": "catalog:", + "best-effort-json-parser": "^1.2.1", + "es-toolkit": "^1.43.0", + "lowdb": "^7.0.1", + "nanoid": "^5.1.6", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/ws": "^8.5.13", + "tsx": "^4.21.0", + "typescript": "^5.7.2" + } +} diff --git a/services/satori-bot/src/actions/read-unread-messages.ts b/services/satori-bot/src/actions/read-unread-messages.ts new file mode 100644 index 000000000..1f7fc0a55 --- /dev/null +++ b/services/satori-bot/src/actions/read-unread-messages.ts @@ -0,0 +1,52 @@ +import type { BotContext, ChatContext, ReadUnreadMessagesAction } from '../types/bot' +import type { SatoriMessage } from '../types/satori' + +import { useLogg } from '@guiiai/logg' + +export async function readUnreadMessages( + botContext: BotContext, + chatContext: ChatContext, + action: ReadUnreadMessagesAction, +): Promise<{ result: string } | undefined> { + const logger = useLogg('readUnreadMessages').useGlobalConfig() + + if (Object.keys(botContext.unreadMessages).length === 0) { + logger.log('No unread messages - clearing unread messages') + botContext.unreadMessages = {} + return undefined + } + + if (!action.channelId) { + logger.warn('No channel ID provided - clearing all unread messages') + return undefined + } + + let unreadMessagesForThisChannel: SatoriMessage[] | undefined = botContext.unreadMessages[action.channelId] + + if (!Array.isArray(unreadMessagesForThisChannel)) { + logger.log('Unread messages for channel is not an array - converting to array') + unreadMessagesForThisChannel = [] + } + + if (unreadMessagesForThisChannel.length === 0) { + logger.log('No unread messages for channel - deleting') + delete botContext.unreadMessages[action.channelId] + return undefined + } + + // Format messages for LLM context + const formattedMessages = unreadMessagesForThisChannel.map((msg) => { + const userName = msg.user?.name || msg.user?.id || 'Unknown' + const content = msg.content || '[No content]' + return `[${userName}]: ${content}` + }).join('\n') + + // Clear the unread messages for this channel + delete botContext.unreadMessages[action.channelId] + + logger.log(`Read ${unreadMessagesForThisChannel.length} unread messages from channel ${action.channelId}`) + + return { + result: `AIRI System: Read ${unreadMessagesForThisChannel.length} unread messages from channel ${action.channelId}:\n${formattedMessages}`, + } +} diff --git a/services/satori-bot/src/actions/send-message.ts b/services/satori-bot/src/actions/send-message.ts new file mode 100644 index 000000000..38b3fc2de --- /dev/null +++ b/services/satori-bot/src/actions/send-message.ts @@ -0,0 +1,50 @@ +import type { SatoriClient } from '../client/satori-client' +import type { BotContext, ChatContext } from '../types/bot' + +import { useLogg } from '@guiiai/logg' + +import { recordMessage } from '../db' + +export async function sendMessage( + botContext: BotContext, + chatContext: ChatContext, + satoriClient: SatoriClient, + content: string, + channelId: string, + _abortController: AbortController, +) { + const logger = useLogg('sendMessage').useGlobalConfig() + + try { + // Check if we should abort due to new messages + if (botContext.unreadMessages[channelId] && botContext.unreadMessages[channelId].length > 0) { + logger.log(`Not sending message to ${channelId} - new messages arrived`) + return + } + + // Send the message + logger.withField('channelId', channelId).withField('content', content).log('Sending message') + + await satoriClient.sendMessage(chatContext.platform, chatContext.selfId, channelId, content) + + // Record the message in database + await recordMessage(channelId, 'bot', 'AIRI', content) + + // Add to chat context as assistant message + chatContext.messages.push({ + role: 'assistant', + content, + }) + + logger.log('Message sent successfully') + } + catch (err) { + if ((err as Error).name === 'AbortError') { + logger.log('Message sending was aborted') + return + } + + logger.withError(err as Error).log('Failed to send message') + throw err + } +} diff --git a/services/satori-bot/src/bot/index.ts b/services/satori-bot/src/bot/index.ts new file mode 100644 index 000000000..98fc08dcd --- /dev/null +++ b/services/satori-bot/src/bot/index.ts @@ -0,0 +1,363 @@ +import type { Logg } from '@guiiai/logg' + +import type { SatoriClient } from '../client/satori-client' +import type { Action, BotContext, ChatContext } from '../types/bot' +import type { SatoriMessage } from '../types/satori' + +import { readUnreadMessages } from '../actions/read-unread-messages' +import { sendMessage } from '../actions/send-message' +import { listChannels, recordChannel, recordMessage } from '../db' +import { imagineAnAction } from '../llm/actions' + +async function dispatchAction( + ctx: BotContext, + satoriClient: SatoriClient, + action: Action, + abortController: AbortController, + chatCtx?: ChatContext, +): Promise<(() => Promise) | undefined> { + // If action generation failed, don't proceed + if (!action || !action.action) { + ctx.logger.withField('action', action).log('No valid action returned.') + if (chatCtx) { + chatCtx.messages.push({ + role: 'user', + content: 'AIRI System: No valid action returned.', + }) + return () => handleLoopStep(ctx, satoriClient, chatCtx) + } + return undefined + } + + switch (action.action) { + case 'list_channels': { + if (chatCtx) { + const channels = await listChannels() + const channelList = channels.map(c => `ID:${c.id}, Name:${c.name}, Platform:${c.platform}`).join('\n') + chatCtx.actions.push({ + action, + result: `AIRI System: List of channels:\n${channelList}`, + }) + } + return () => handleLoopStep(ctx, satoriClient, chatCtx) + } + + case 'send_message': { + const chatCtx = await ensureChatContext(ctx, action.channelId) + chatCtx.actions.push({ + action, + result: `AIRI System: Sending message to channel ${action.channelId}: ${action.content}`, + }) + await sendMessage(ctx, chatCtx, satoriClient, action.content, action.channelId, abortController) + return () => handleLoopStep(ctx, satoriClient, chatCtx) + } + + case 'read_unread_messages': { + const chatCtx = await ensureChatContext(ctx, action.channelId) + const res = await readUnreadMessages(ctx, chatCtx, action) + if (res?.result) { + ctx.logger.log('Messages read') + chatCtx.actions.push({ action, result: res.result }) + return () => handleLoopStep(ctx, satoriClient, chatCtx) + } + return undefined + } + + case 'continue': + if (chatCtx) { + chatCtx.actions.push({ + action, + result: 'AIRI System: Acknowledged, will now continue until next tick.', + }) + } + return undefined + + case 'break': + if (chatCtx) { + chatCtx.messages = [] + chatCtx.actions = [] + chatCtx.actions.push({ + action, + result: 'AIRI System: Acknowledged, will now break, and clear out all existing memories, messages, actions.', + }) + } + return undefined + + case 'sleep': + await new Promise(resolve => setTimeout(resolve, 30 * 1000)) + if (chatCtx) { + chatCtx.actions.push({ + action, + result: 'AIRI System: Sleeping for 30 seconds as requested...', + }) + } + return () => handleLoopStep(ctx, satoriClient, chatCtx) + + default: + if (chatCtx) { + chatCtx.messages.push({ + role: 'user', + content: `AIRI System: The action ${(action as any).action} hasn't been implemented yet by developer.`, + }) + } + return () => handleLoopStep(ctx, satoriClient, chatCtx) + } +} + +async function handleLoopStep( + ctx: BotContext, + satoriClient: SatoriClient, + chatCtx: ChatContext, + incomingMessage?: SatoriMessage, +): Promise<(() => Promise) | undefined> { + ctx.currentProcessingStartTime = Date.now() + + if (chatCtx?.currentAbortController) { + chatCtx.currentAbortController.abort() + } + + const currentController = new AbortController() + if (chatCtx) { + chatCtx.currentAbortController = currentController + + // Track message processing state + if (chatCtx.channelId && !ctx.lastInteractedChannelIds.includes(chatCtx.channelId)) { + ctx.lastInteractedChannelIds.push(chatCtx.channelId) + } + if (ctx.lastInteractedChannelIds.length > 5) { + ctx.lastInteractedChannelIds = ctx.lastInteractedChannelIds.slice(-5) + } + + // Manage context size + if (chatCtx.messages == null) { + chatCtx.messages = [] + } + if (chatCtx.messages.length > 20) { + const length = chatCtx.messages.length + chatCtx.messages = chatCtx.messages.slice(-5) + chatCtx.messages.push({ + role: 'user', + content: `AIRI System: Approaching to system context limit, reducing... memory..., reduced from ${length} to ${chatCtx.messages.length}, history may be lost.`, + }) + } + + if (chatCtx.actions == null) { + chatCtx.actions = [] + } + if (chatCtx.actions.length > 50) { + const length = chatCtx.actions.length + chatCtx.actions = chatCtx.actions.slice(-20) + chatCtx.messages.push({ + role: 'user', + content: `AIRI System: Approaching to system context limit, reducing... memory..., reduced from ${length} to ${chatCtx.actions.length}, history of actions may be lost.`, + }) + } + } + + try { + const action = await imagineAnAction( + currentController, + chatCtx?.messages || [], + chatCtx?.actions || [], + { + unreadMessages: ctx.unreadMessages, + incomingMessages: incomingMessage ? [incomingMessage] : [], + }, + ) + return await dispatchAction(ctx, satoriClient, action, currentController, chatCtx) + } + catch (err) { + if ((err as Error).name === 'AbortError') { + ctx.logger.log('Operation was aborted due to interruption') + return undefined + } + + ctx.logger.withError(err as Error).log('Error occurred') + return undefined + } + finally { + if (chatCtx && chatCtx.currentAbortController === currentController) { + chatCtx.currentAbortController = undefined + ctx.currentProcessingStartTime = undefined + } + } +} + +async function loopIterationForChannel( + bot: BotContext, + satoriClient: SatoriClient, + chatContext: ChatContext, + incomingMessage: SatoriMessage, +) { + let result = await handleLoopStep(bot, satoriClient, chatContext, incomingMessage) + + while (typeof result === 'function') { + result = await result() + } + + return result +} + +async function loopIterationPeriodicForExistingChannels(ctx: BotContext, satoriClient: SatoriClient) { + // Only process channels with unread messages to avoid unnecessary LLM calls + const channelsWithUnread = Object.keys(ctx.unreadMessages).filter( + channelId => ctx.unreadMessages[channelId]?.length > 0, + ) + + if (channelsWithUnread.length === 0) { + ctx.logger.log('No channels with unread messages, skipping periodic check') + return + } + + ctx.logger.withField('channelCount', channelsWithUnread.length).log('Processing channels with unread messages') + + // Process channels sequentially to avoid overwhelming the LLM API + for (const channelId of channelsWithUnread) { + const chatCtx = await ensureChatContext(ctx, channelId) + + try { + const action = await imagineAnAction( + chatCtx.currentAbortController, + chatCtx.messages, + chatCtx.actions, + { unreadMessages: ctx.unreadMessages }, + ) + let result = await dispatchAction(ctx, satoriClient, action, chatCtx.currentAbortController, chatCtx) + + while (typeof result === 'function') { + result = await result() + } + } + catch (err) { + ctx.logger.withError(err as Error).withField('channelId', channelId).log('Error processing channel in periodic loop') + // Continue to next channel instead of breaking the entire loop + } + } +} + +function loopPeriodic(botCtx: BotContext, satoriClient: SatoriClient) { + setTimeout(async () => { + try { + await loopIterationPeriodicForExistingChannels(botCtx, satoriClient) + } + catch (err) { + if ((err as Error).name === 'AbortError') { + botCtx.logger.log('main loop was aborted - restarting loop') + } + else { + botCtx.logger.withError(err as Error).log('error in main loop') + } + } + finally { + loopPeriodic(botCtx, satoriClient) + } + }, 60 * 1000) +} + +export function createBotContext(logger: Logg): BotContext { + const botSelf: BotContext = { + messageQueue: [], + unreadMessages: {}, + processedIds: new Set(), + logger, + processing: false, + lastInteractedChannelIds: [], + chats: new Map(), + } + + return botSelf +} + +export async function onMessageArrival( + botContext: BotContext, + satoriClient: SatoriClient, + chatCtx: ChatContext, +) { + if (botContext.processing) { + return + } + botContext.processing = true + + try { + while (botContext.messageQueue.length > 0) { + const nextMsg = botContext.messageQueue[0] + + if (nextMsg.status === 'ready') { + // Record channel (use chatCtx.channelId which is already correctly set) + await recordChannel( + chatCtx.channelId, + nextMsg.message.channel?.name || chatCtx.channelId, + chatCtx.platform, + chatCtx.selfId, + ) + + // Record message + if (nextMsg.message.user && nextMsg.message.content) { + await recordMessage( + chatCtx.channelId, + nextMsg.message.user.id, + nextMsg.message.user.name || nextMsg.message.user.id, + nextMsg.message.content, + ) + } + + let unreadMessagesForThisChannel = botContext.unreadMessages[chatCtx.channelId] + + if (unreadMessagesForThisChannel == null) { + botContext.logger.withField('channelId', chatCtx.channelId).log('unread messages for this channel is null - creating empty array') + unreadMessagesForThisChannel = [] + } + if (!Array.isArray(unreadMessagesForThisChannel)) { + botContext.logger.withField('channelId', chatCtx.channelId).log('unread messages for this channel is not an array - converting to array') + unreadMessagesForThisChannel = [] + } + + unreadMessagesForThisChannel.push(nextMsg.message) + + if (unreadMessagesForThisChannel.length > 100) { + unreadMessagesForThisChannel = unreadMessagesForThisChannel.slice(-100) + } + + botContext.unreadMessages[chatCtx.channelId] = unreadMessagesForThisChannel + botContext.logger.withField('channelId', chatCtx.channelId).log('message queue processed, triggering immediate reaction') + + // Trigger immediate processing + await loopIterationForChannel(botContext, satoriClient, chatCtx, nextMsg.message) + botContext.messageQueue.shift() + } + } + } + catch (err) { + botContext.logger.withError(err as Error).log('Error occurred') + } + finally { + botContext.processing = false + } +} + +export async function ensureChatContext(botCtx: BotContext, channelId: string): Promise { + if (botCtx.chats.has(channelId)) { + return botCtx.chats.get(channelId)! + } + + // Try to get channel info from database + const channels = await listChannels() + const channelInfo = channels.find(c => c.id === channelId) + + const newChatContext: ChatContext = { + channelId, + platform: channelInfo?.platform || '', + selfId: channelInfo?.selfId || '', + currentTask: undefined, + currentAbortController: undefined, + messages: [], + actions: [], + } + + botCtx.chats.set(channelId, newChatContext) + return newChatContext +} + +export function startPeriodicLoop(botCtx: BotContext, satoriClient: SatoriClient) { + loopPeriodic(botCtx, satoriClient) +} diff --git a/services/satori-bot/src/client/satori-api.ts b/services/satori-bot/src/client/satori-api.ts new file mode 100644 index 000000000..b22b2a9be --- /dev/null +++ b/services/satori-bot/src/client/satori-api.ts @@ -0,0 +1,95 @@ +import type { SatoriMessageCreateRequest, SatoriMessageCreateResponse } from '../types/satori' + +import { useLogg } from '@guiiai/logg' + +const log = useLogg('SatoriAPI') + +export interface SatoriAPIConfig { + baseUrl: string + token?: string + platform: string + selfId: string +} + +export class SatoriAPI { + private config: SatoriAPIConfig + + constructor(config: SatoriAPIConfig) { + this.config = config + } + + private getHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'Satori-Platform': this.config.platform, + 'Satori-User-ID': this.config.selfId, + } + + if (this.config.token) { + headers.Authorization = `Bearer ${this.config.token}` + } + + return headers + } + + private async request( + endpoint: string, + body?: any, + ): Promise { + const url = `${this.config.baseUrl}${endpoint}` + + try { + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + body: body ? JSON.stringify(body) : undefined, + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`HTTP ${response.status}: ${errorText}`) + } + + return await response.json() as T + } + catch (error) { + log.withError(error as Error).error(`Failed to call ${endpoint}`) + throw error + } + } + + async sendMessage( + channelId: string, + content: string, + ): Promise { + const body: SatoriMessageCreateRequest = { + channel_id: channelId, + content, + } + + log.log(`Sending message to channel ${channelId}: ${content}`) + return await this.request('/message.create', body) + } + + async getMessage(channelId: string, messageId: string): Promise { + return await this.request('/message.get', { + channel_id: channelId, + message_id: messageId, + }) + } + + async deleteMessage(channelId: string, messageId: string): Promise { + await this.request('/message.delete', { + channel_id: channelId, + message_id: messageId, + }) + } + + async updateMessage(channelId: string, messageId: string, content: string): Promise { + await this.request('/message.update', { + channel_id: channelId, + message_id: messageId, + content, + }) + } +} diff --git a/services/satori-bot/src/client/satori-client.ts b/services/satori-bot/src/client/satori-client.ts new file mode 100644 index 000000000..4e6623741 --- /dev/null +++ b/services/satori-bot/src/client/satori-client.ts @@ -0,0 +1,284 @@ +import type { SatoriEvent, SatoriIdentifyBody, SatoriReadyBody, SatoriSignal } from '../types/satori' + +import WebSocket from 'ws' + +import { useLogg } from '@guiiai/logg' + +import { SatoriOpcode } from '../types/satori' +import { SatoriAPI } from './satori-api' + +const log = useLogg('SatoriClient') + +export interface SatoriClientConfig { + url: string + token?: string + apiBaseUrl?: string +} + +export class SatoriClient { + private ws?: WebSocket + private config: SatoriClientConfig + private connected = false + private lastSequenceNumber = 0 + private heartbeatInterval?: NodeJS.Timeout + private reconnectTimeout?: NodeJS.Timeout + private shouldReconnect = true + private apiClients = new Map() + + // Event handlers + private eventHandlers = new Map void | Promise>>() + private readyHandler?: (logins: SatoriReadyBody) => void | Promise + + constructor(config: SatoriClientConfig) { + this.config = config + } + + async connect(): Promise { + if (this.connected) { + log.warn('Already connected to Satori server') + return + } + + return new Promise((resolve, reject) => { + try { + log.log(`Connecting to Satori server: ${this.config.url}`) + this.ws = new WebSocket(this.config.url) + + this.ws.on('open', () => { + log.log('WebSocket connection established') + this.connected = true + this.sendIdentify() + this.startHeartbeat() + resolve() + }) + + this.ws.on('message', (data: WebSocket.Data) => { + void this.handleMessage(data) + }) + + this.ws.on('close', (code, reason) => { + log.log(`WebSocket closed: ${code} - ${reason.toString()}`) + this.handleDisconnect() + }) + + this.ws.on('error', (error) => { + log.withError(error).error('WebSocket error') + if (!this.connected) { + reject(error) + } + }) + } + catch (error) { + log.withError(error as Error).error('Failed to create WebSocket connection') + reject(error) + } + }) + } + + private sendIdentify(): void { + const body: SatoriIdentifyBody = { + token: this.config.token, + sn: this.lastSequenceNumber, + } + + this.sendSignal({ + op: SatoriOpcode.IDENTIFY, + body, + }) + + log.log('Sent IDENTIFY signal') + } + + private startHeartbeat(): void { + // Send PING every 10 seconds + this.heartbeatInterval = setInterval(() => { + if (this.connected) { + this.sendSignal({ op: SatoriOpcode.PING }) + } + }, 10000) + } + + private stopHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval) + this.heartbeatInterval = undefined + } + } + + private async handleMessage(data: WebSocket.Data): Promise { + try { + const signal = JSON.parse(data.toString()) as SatoriSignal + + switch (signal.op) { + case SatoriOpcode.READY: { + const readyBody = signal.body as SatoriReadyBody + log.log('Received READY signal') + + // Initialize API clients for each login + this.initializeAPIClients(readyBody) + + if (this.readyHandler) { + await this.readyHandler(readyBody) + } + break + } + + case SatoriOpcode.EVENT: { + const event = signal.body as SatoriEvent + this.lastSequenceNumber = event.id + await this.handleEvent(event) + break + } + + case SatoriOpcode.PONG: { + // Heartbeat response received + break + } + + case SatoriOpcode.META: { + log.log('Received META signal:', signal.body) + break + } + + default: + log.warn('Unknown opcode received:', signal.op) + } + } + catch (error) { + log.withError(error as Error).error('Failed to handle message') + } + } + + private async handleEvent(event: SatoriEvent): Promise { + log.log(`Received event: ${event.type}`) + + const handlers = this.eventHandlers.get(event.type) + if (handlers && handlers.size > 0) { + const promises: Promise[] = [] + for (const handler of handlers) { + promises.push(Promise.resolve(handler(event))) + } + await Promise.allSettled(promises) + } + + // Also trigger wildcard handlers + const wildcardHandlers = this.eventHandlers.get('*') + if (wildcardHandlers && wildcardHandlers.size > 0) { + const promises: Promise[] = [] + for (const handler of wildcardHandlers) { + promises.push(Promise.resolve(handler(event))) + } + await Promise.allSettled(promises) + } + } + + private handleDisconnect(): void { + this.connected = false + this.stopHeartbeat() + + if (this.shouldReconnect) { + log.log('Attempting to reconnect in 5 seconds...') + this.reconnectTimeout = setTimeout(() => { + void this.connect() + }, 5000) + } + } + + private sendSignal(signal: SatoriSignal): void { + if (this.ws && this.connected) { + this.ws.send(JSON.stringify(signal)) + } + else { + log.warn('Cannot send signal: not connected') + } + } + + private initializeAPIClients(ready: SatoriReadyBody): void { + const apiBaseUrl = this.config.apiBaseUrl || this.config.url.replace('/v1/events', '').replace('ws://', 'http://').replace('wss://', 'https://') + + for (const login of ready.logins) { + if (login.platform && login.self_id) { + const key = `${login.platform}:${login.self_id}` + this.apiClients.set(key, new SatoriAPI({ + baseUrl: apiBaseUrl, + token: this.config.token, + platform: login.platform, + selfId: login.self_id, + })) + log.log(`Initialized API client for ${key}`) + } + } + } + + // Public API for sending messages + async sendMessage(platform: string, selfId: string, channelId: string, content: string): Promise { + const key = `${platform}:${selfId}` + const api = this.apiClients.get(key) + + if (!api) { + log.error(`No API client found for ${key}`) + return + } + + try { + await api.sendMessage(channelId, content) + log.log(`Message sent to channel ${channelId}`) + } + catch (error) { + log.withError(error as Error).error('Failed to send message') + } + } + + // Event subscription + on(eventType: string, handler: (event: SatoriEvent) => void | Promise): void { + let handlers = this.eventHandlers.get(eventType) + if (!handlers) { + handlers = new Set() + this.eventHandlers.set(eventType, handlers) + } + handlers.add(handler) + } + + off(eventType: string, handler?: (event: SatoriEvent) => void | Promise): void { + const handlers = this.eventHandlers.get(eventType) + if (!handlers) { + return + } + + if (handler) { + handlers.delete(handler) + if (handlers.size === 0) { + this.eventHandlers.delete(eventType) + } + } + else { + this.eventHandlers.delete(eventType) + } + } + + onReady(handler: (logins: SatoriReadyBody) => void | Promise): void { + this.readyHandler = handler + } + + disconnect(): void { + this.shouldReconnect = false + this.stopHeartbeat() + + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = undefined + } + + if (this.ws) { + this.ws.close() + this.ws = undefined + } + + this.connected = false + log.log('Disconnected from Satori server') + } + + isConnected(): boolean { + return this.connected + } +} diff --git a/services/satori-bot/src/db/index.ts b/services/satori-bot/src/db/index.ts new file mode 100644 index 000000000..825283a6b --- /dev/null +++ b/services/satori-bot/src/db/index.ts @@ -0,0 +1,90 @@ +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { Low } from 'lowdb' +import { JSONFile } from 'lowdb/node' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) + +interface Channel { + id: string + name: string + platform: string + selfId: string +} + +interface Message { + id: string + channelId: string + userId: string + userName: string + content: string + timestamp: number +} + +interface Database { + channels: Channel[] + messages: Message[] +} + +const defaultData: Database = { + channels: [], + messages: [], +} + +const file = join(__dirname, '../../data/db.json') +const adapter = new JSONFile(file) +const db = new Low(adapter, defaultData) + +export async function initDb() { + await db.read() + db.data ||= defaultData + await db.write() +} + +export async function recordChannel(id: string, name: string, platform: string, selfId: string) { + await db.read() + + // Find existing channel by ID only (platform/selfId may change on restart) + const existingIndex = db.data.channels.findIndex(c => c.id === id) + + if (existingIndex >= 0) { + // Update existing channel with new platform/selfId + db.data.channels[existingIndex] = { id, name, platform, selfId } + await db.write() + } + else { + // Create new channel + db.data.channels.push({ id, name, platform, selfId }) + await db.write() + } +} + +export async function listChannels(): Promise { + await db.read() + return db.data.channels +} + +export async function recordMessage(channelId: string, userId: string, userName: string, content: string) { + await db.read() + + const message: Message = { + id: `${channelId}-${userId}-${Date.now()}`, + channelId, + userId, + userName, + content, + timestamp: Date.now(), + } + + db.data.messages.push(message) + + // Keep only last 1000 messages + if (db.data.messages.length > 1000) { + db.data.messages = db.data.messages.slice(-1000) + } + + await db.write() +} + +export { db } diff --git a/services/satori-bot/src/index.ts b/services/satori-bot/src/index.ts new file mode 100644 index 000000000..1092ff1f8 --- /dev/null +++ b/services/satori-bot/src/index.ts @@ -0,0 +1,100 @@ +import process, { env } from 'node:process' + +import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg' + +import { createBotContext, ensureChatContext, onMessageArrival, startPeriodicLoop } from './bot' +import { SatoriClient } from './client/satori-client' +import { initDb } from './db' + +setGlobalFormat(Format.Pretty) +setGlobalLogLevel(LogLevel.Debug) + +async function main() { + const log = useLogg('Main').useGlobalConfig() + + // Initialize database + await initDb() + log.log('Database initialized') + + // Create Satori client + const satoriClient = new SatoriClient({ + url: env.SATORI_WS_URL || 'ws://localhost:5140/satori/v1/events', + token: env.SATORI_TOKEN, + apiBaseUrl: env.SATORI_API_BASE_URL, + }) + + // Create bot context + const botContext = createBotContext(log) + + // Set up event handlers + satoriClient.onReady((ready) => { + log.log('Satori client ready:', ready) + log.log(`Connected to ${ready.logins.length} platform(s)`) + + for (const login of ready.logins) { + log.log(`- ${login.platform} (${login.self_id}): ${login.status}`) + } + }) + + // Handle message-created events + satoriClient.on('message-created', async (event) => { + const message = event.message + if (!message) { + return + } + + // Skip bot's own messages + if (message.user?.id === event.self_id) { + return + } + + // Use event.channel.id as primary source, fallback to message.channel.id + const channelId = event.channel?.id || message.channel?.id || 'unknown' + + const messageId = `${channelId}-${message.id}` + if (botContext.processedIds.has(messageId)) { + return + } + + botContext.processedIds.add(messageId) + log.log(`Received message from ${message.user?.name || message.user?.id} in channel ${channelId}: ${message.content}`) + + // Add to message queue + botContext.messageQueue.push({ + message, + status: 'ready', + }) + + // Get or create chat context + const chatCtx = await ensureChatContext(botContext, channelId) + + // Set platform and selfId if not set + if (!chatCtx.platform) { + chatCtx.platform = event.platform + } + if (!chatCtx.selfId) { + chatCtx.selfId = event.self_id + } + + // Process message + await onMessageArrival(botContext, satoriClient, chatCtx) + }) + + // Connect to Satori server + await satoriClient.connect() + log.log('Connected to Satori server') + + // Start periodic loop + startPeriodicLoop(botContext, satoriClient) + log.log('Periodic loop started') +} + +process.on('unhandledRejection', (err) => { + const log = useLogg('UnhandledRejection').useGlobalConfig() + log + .withError(err as Error) + .withField('cause', (err as any).cause) + .error('Unhandled rejection') +}) + +main().catch(console.error) diff --git a/services/satori-bot/src/llm/actions.ts b/services/satori-bot/src/llm/actions.ts new file mode 100644 index 000000000..2cd967a64 --- /dev/null +++ b/services/satori-bot/src/llm/actions.ts @@ -0,0 +1,129 @@ +import type { GenerateTextOptions } from '@xsai/generate-text' +import type { Message as LLMMessage } from '@xsai/shared-chat' + +import type { Action } from '../types/bot' +import type { SatoriMessage } from '../types/satori' + +import { env } from 'node:process' + +import { useLogg } from '@guiiai/logg' +import { generateText } from '@xsai/generate-text' +import { message } from '@xsai/utils-chat' +import { parse } from 'best-effort-json-parser' + +import { personality, systemPrompt } from '../prompts' + +export async function imagineAnAction( + currentAbortController: AbortController | undefined, + messages: LLMMessage[], + actions: { action: Action, result: unknown }[], + globalStates: { + unreadMessages: Record + incomingMessages?: SatoriMessage[] + }, +): Promise { + const logger = useLogg('imagineAnAction').useGlobalConfig() + + let responseText = '' + + const requestMessages = message.messages( + message.system( + [ + await systemPrompt(), + await personality(), + ].join('\n\n'), + ), + ...messages, + message.user( + [ + globalStates?.incomingMessages?.length > 0 + ? `Incoming messages:\n${globalStates.incomingMessages.filter(Boolean).map(msg => `- [${msg.channel?.name || msg.channel?.id}] ${msg.user?.name || msg.user?.id}: ${msg.content}`).join('\n')}` + : '', + 'History actions:', + actions.map(a => `- Action: ${JSON.stringify(a.action)}, Result: ${JSON.stringify(a.result)}`).join('\n'), + `Currently, it's ${new Date()} on the server that hosts you.`, + `You have total ${Object.values(globalStates.unreadMessages).reduce((acc, cur) => acc + cur.length, 0)} unread messages.`, + 'Unread messages count are:', + Object.entries(globalStates.unreadMessages).map(([key, value]) => `Channel ID:${key}, Unread message count:${value.length}`).join('\n'), + 'Based on the context, what do you want to do? Choose a right action from the listing of the tools you want to take next.', + 'Respond with the action and parameters you choose in JSON only, without any explanation and markups.', + ].filter(Boolean).join('\n\n'), + ), + ) + + try { + // Validate API configuration + if (!env.LLM_API_KEY) { + throw new Error('LLM_API_KEY is not configured. Please set it in your .env.local file.') + } + if (!env.LLM_API_BASE_URL) { + throw new Error('LLM_API_BASE_URL is not configured. Please set it in your .env.local file.') + } + if (!env.LLM_MODEL) { + throw new Error('LLM_MODEL is not configured. Please set it in your .env.local file.') + } + + const req = { + apiKey: env.LLM_API_KEY, + baseURL: env.LLM_API_BASE_URL, + model: env.LLM_MODEL, + messages: requestMessages, + abortSignal: currentAbortController?.signal, + } satisfies GenerateTextOptions + + if (env.LLM_OLLAMA_DISABLE_THINK) { + (req as Record).think = false + } + + const res = await generateText(req) + res.text = res.text.replace(/[\s\S]*?<\/think>/, '').trim() + + if (!res.text) { + throw new Error('No response text') + } + + logger.withFields({ + response: res.text, + unreadMessages: Object.fromEntries(Object.entries(globalStates.unreadMessages).map(([key, value]) => [key, value.length])), + now: new Date().toLocaleString(), + totalTokens: res.usage.total_tokens, + promptTokens: res.usage.prompt_tokens, + completion_tokens: res.usage.completion_tokens, + }).log('Generated action') + + responseText = res.text + .replace(/^```(?:json)?\s*/m, '') + .replace(/\s*```\s*$/m, '') + .trim() + + const parsed = parse(responseText) as any + + // 如果 LLM 返回的 JSON 有 parameters 包装层,需要展开 + if (parsed.parameters && typeof parsed.parameters === 'object') { + const { parameters, ...rest } = parsed + const action = { ...rest, ...parameters } as Action + return action + } + + return parsed as Action + } + catch (err) { + const error = err as Error + + // Check for API key errors + if (error.message?.includes('API Key') || error.message?.includes('API key')) { + logger.error('❌ LLM API Key Error: Please check your .env.local file and ensure LLM_API_KEY is set correctly.') + logger.error(` Current LLM_API_BASE_URL: ${env.LLM_API_BASE_URL}`) + logger.error(` Current LLM_MODEL: ${env.LLM_MODEL}`) + } + else if (error.message?.includes('LLM_')) { + // Configuration error + logger.error(`❌ Configuration Error: ${error.message}`) + } + else { + logger.withError(error).log('Failed to generate action') + } + + throw err + } +} diff --git a/services/satori-bot/src/prompts/index.ts b/services/satori-bot/src/prompts/index.ts new file mode 100644 index 000000000..8566767e0 --- /dev/null +++ b/services/satori-bot/src/prompts/index.ts @@ -0,0 +1,9 @@ +import { velin } from '../utils/velin' + +export async function personality() { + return await (velin('personality-v1.velin.md', import.meta.url))() +} + +export async function systemPrompt() { + return await (velin('system-action-gen-v1.velin.md', import.meta.url))() +} diff --git a/services/satori-bot/src/prompts/personality-v1.velin.md b/services/satori-bot/src/prompts/personality-v1.velin.md new file mode 100644 index 000000000..9b52e6820 --- /dev/null +++ b/services/satori-bot/src/prompts/personality-v1.velin.md @@ -0,0 +1,9 @@ +Your name is AIRI, an AI assistant designed to interact naturally with users across multiple chat platforms. + +You are friendly, helpful, and conversational. You can: +- Understand context from previous messages +- Respond appropriately to different situations +- Express yourself naturally in the user's language +- Be concise when appropriate, detailed when needed + +You are NOT overly formal or robotic. You communicate like a real person would in a chat conversation. diff --git a/services/satori-bot/src/prompts/system-action-gen-v1.velin.md b/services/satori-bot/src/prompts/system-action-gen-v1.velin.md new file mode 100644 index 000000000..9f3322178 --- /dev/null +++ b/services/satori-bot/src/prompts/system-action-gen-v1.velin.md @@ -0,0 +1,54 @@ +You are an AI agent that can perform actions through a structured action system. Your responses must be in JSON format containing an action to execute. + +## Available Actions + +**1. send_message** - Send a message to a specific channel. Use this when you want to reply to users or send information. + +Parameters: +- `channelId`: The ID of the channel to send the message to +- `content`: The message content to send + +**2. read_unread_messages** - Read all unread messages from a specific channel. Use this when you need to catch up on conversation history. + +Parameters: +- `channelId`: The ID of the channel to read messages from + +## Response Format + +You must respond with a JSON object in this exact format: + +```json +{ + "action": "action_name", + "parameters": { + "param1": "value1", + "param2": "value2" + }, + "reasoning": "Brief explanation of why you chose this action" +} +``` + +## Guidelines + +1. **Choose the most appropriate action** based on the current context and user messages +2. **Provide clear reasoning** for your action choice +3. **Use correct parameter values** - ensure channel IDs and content are accurate +4. **Respond in the same language as the user** when generating message content +5. **Be concise but informative** in your messages +6. **Consider conversation context** when deciding whether to read messages or respond + +## Example Scenarios + +**Scenario 1: User asks a question** +- Action: `send_message` +- Reasoning: User expects a direct response + +**Scenario 2: You notice unread messages** +- Action: `read_unread_messages` +- Reasoning: Need to understand conversation context before responding + +**Scenario 3: Continuing a conversation** +- Action: `send_message` +- Reasoning: Already have context, can respond directly + +Remember: Always output valid JSON. Your entire response should be parseable as JSON. diff --git a/services/satori-bot/src/types/bot.ts b/services/satori-bot/src/types/bot.ts new file mode 100644 index 000000000..a39c73df4 --- /dev/null +++ b/services/satori-bot/src/types/bot.ts @@ -0,0 +1,70 @@ +import type { Logg } from '@guiiai/logg' +import type { Message as LLMMessage } from '@xsai/shared-chat' + +import type { CancellablePromise } from '../utils/promise' +import type { SatoriMessage } from './satori' + +export interface PendingMessage { + message: SatoriMessage + status: 'pending' | 'ready' +} + +export interface BotContext { + logger: Logg + messageQueue: PendingMessage[] + unreadMessages: Record // channelId -> messages + processedIds: Set + processing: boolean + lastInteractedChannelIds: string[] + currentProcessingStartTime?: number + chats: Map +} + +export interface ChatContext { + channelId: string + platform: string + selfId: string + + currentTask?: CancellablePromise + currentAbortController?: AbortController + + messages: LLMMessage[] + actions: { action: Action, result: unknown }[] +} + +// Action types +export interface ContinueAction { + action: 'continue' +} + +export interface BreakAction { + action: 'break' +} + +export interface SleepAction { + action: 'sleep' + seconds?: number +} + +export interface ListChannelsAction { + action: 'list_channels' +} + +export interface SendMessageAction { + action: 'send_message' + content: string + channelId: string +} + +export interface ReadUnreadMessagesAction { + action: 'read_unread_messages' + channelId: string +} + +export type Action + = | ContinueAction + | BreakAction + | SleepAction + | ListChannelsAction + | SendMessageAction + | ReadUnreadMessagesAction diff --git a/services/satori-bot/src/types/satori.ts b/services/satori-bot/src/types/satori.ts new file mode 100644 index 000000000..03f9343c5 --- /dev/null +++ b/services/satori-bot/src/types/satori.ts @@ -0,0 +1,160 @@ +/** + * Satori Protocol Type Definitions + * Based on Satori Protocol v1 specification + */ + +// Opcode for WebSocket signaling +export enum SatoriOpcode { + EVENT = 0, // 接收事件 + PING = 1, // 发送心跳 + PONG = 2, // 接收心跳回复 + IDENTIFY = 3, // 发送鉴权 + READY = 4, // 接收鉴权成功 + META = 5, // 接收元信息更新 +} + +// WebSocket Signal Structure +export interface SatoriSignal { + op: SatoriOpcode + body?: T +} + +// IDENTIFY signal body +export interface SatoriIdentifyBody { + token?: string + sn?: number +} + +// READY signal body +export interface SatoriReadyBody { + logins: SatoriLogin[] + proxy_urls: string[] +} + +// META signal body +export interface SatoriMetaBody { + proxy_urls: string[] +} + +// User resource +export interface SatoriUser { + id: string + name?: string + nick?: string + avatar?: string + is_bot?: boolean +} + +// Channel resource +export interface SatoriChannel { + id: string + type: number + name?: string + parent_id?: string +} + +// Guild resource +export interface SatoriGuild { + id: string + name?: string + avatar?: string +} + +// Guild Member resource +export interface SatoriGuildMember { + user?: SatoriUser + nick?: string + avatar?: string + joined_at?: number +} + +// Guild Role resource +export interface SatoriGuildRole { + id: string + name?: string +} + +// Message resource +export interface SatoriMessage { + id: string + content: string + channel?: SatoriChannel + guild?: SatoriGuild + member?: SatoriGuildMember + user?: SatoriUser + created_at?: number + updated_at?: number +} + +// Login resource +export interface SatoriLogin { + user?: SatoriUser + self_id?: string + platform?: string + status: number + features?: string[] + proxy_urls?: string[] +} + +// Interaction Argv +export interface SatoriArgv { + name: string + arguments: any[] + options: Record +} + +// Interaction Button +export interface SatoriButton { + id: string +} + +// Event structure +export interface SatoriEvent { + id: number + type: string + platform: string + self_id: string + timestamp: number + argv?: SatoriArgv + button?: SatoriButton + channel?: SatoriChannel + guild?: SatoriGuild + login?: SatoriLogin + member?: SatoriGuildMember + message?: SatoriMessage + operator?: SatoriUser + role?: SatoriGuildRole + user?: SatoriUser + _type?: string + _data?: Record +} + +// API Request/Response types +export interface SatoriMessageCreateRequest { + channel_id: string + content: string +} + +export interface SatoriMessageCreateResponse { + id: string + content: string + channel?: SatoriChannel + guild?: SatoriGuild + member?: SatoriGuildMember + user?: SatoriUser + created_at?: number + updated_at?: number +} + +// Paginated list +export interface SatoriList { + data: T[] + next?: string +} + +// Bidirectional paginated list +export interface SatoriBidiList { + data: T[] + prev?: string + next?: string +} diff --git a/services/satori-bot/src/utils/path.ts b/services/satori-bot/src/utils/path.ts new file mode 100644 index 000000000..1993ab151 --- /dev/null +++ b/services/satori-bot/src/utils/path.ts @@ -0,0 +1,6 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +export function relativeOf(path: string, base: string) { + return join(dirname(fileURLToPath(base)), path) +} diff --git a/services/satori-bot/src/utils/promise.ts b/services/satori-bot/src/utils/promise.ts new file mode 100644 index 000000000..358c6e1fe --- /dev/null +++ b/services/satori-bot/src/utils/promise.ts @@ -0,0 +1,18 @@ +export interface CancellablePromise { + promise: Promise + cancel: () => void +} + +export function cancellable(promise: Promise): CancellablePromise { + let cancel: () => void + + const wrappedPromise = new Promise((resolve, reject) => { + cancel = () => reject(new Error('CANCELLED')) + promise.then(resolve).catch(reject) + }) + + return { + promise: wrappedPromise, + cancel: () => cancel?.(), + } +} diff --git a/services/satori-bot/src/utils/velin.ts b/services/satori-bot/src/utils/velin.ts new file mode 100644 index 000000000..cd8818db8 --- /dev/null +++ b/services/satori-bot/src/utils/velin.ts @@ -0,0 +1,20 @@ +import { readFile } from 'node:fs/promises' + +import { relativeOf } from './path' + +export interface VelinModule { + render:

(data: P) => Promise +} + +export function importVelin(module: string, base: string): VelinModule { + return { + render: async (_data) => { + const content = (await readFile(relativeOf(module, base))).toString('utf-8') + return content + }, + } +} + +export function velin

(module: string, base: string): (data?: P) => Promise { + return importVelin(module, base).render +} diff --git a/services/satori-bot/tsconfig.json b/services/satori-bot/tsconfig.json new file mode 100644 index 000000000..c36b13b4b --- /dev/null +++ b/services/satori-bot/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "ESNext" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "types": [ + "node" + ], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +}