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 <hoshino-yumetsuki@outlook.com>
Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
akuuma
2026-01-06 15:32:20 +08:00
committed by GitHub
co-authored by gemini-code-assist[bot] Hoshino-Yumetsuki Neko
parent dec9123c5a
commit ad086535e5
25 changed files with 1810 additions and 48 deletions
+87 -30
View File
@@ -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:
+15 -17
View File
@@ -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
+1 -1
View File
@@ -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",
+11
View File
@@ -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
+120
View File
@@ -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/)
+1
View File
@@ -0,0 +1 @@
+4
View File
@@ -0,0 +1,4 @@
{
"channels": [],
"messages": []
}
+41
View File
@@ -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"
}
}
@@ -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}`,
}
}
@@ -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
}
}
+363
View File
@@ -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<any>) | 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<any>) | 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<string, ChatContext>(),
}
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<ChatContext> {
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)
}
@@ -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<string, string> {
const headers: Record<string, string> = {
'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<T>(
endpoint: string,
body?: any,
): Promise<T> {
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<SatoriMessageCreateResponse[]> {
const body: SatoriMessageCreateRequest = {
channel_id: channelId,
content,
}
log.log(`Sending message to channel ${channelId}: ${content}`)
return await this.request<SatoriMessageCreateResponse[]>('/message.create', body)
}
async getMessage(channelId: string, messageId: string): Promise<any> {
return await this.request('/message.get', {
channel_id: channelId,
message_id: messageId,
})
}
async deleteMessage(channelId: string, messageId: string): Promise<void> {
await this.request('/message.delete', {
channel_id: channelId,
message_id: messageId,
})
}
async updateMessage(channelId: string, messageId: string, content: string): Promise<void> {
await this.request('/message.update', {
channel_id: channelId,
message_id: messageId,
content,
})
}
}
@@ -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<string, SatoriAPI>()
// Event handlers
private eventHandlers = new Map<string, Set<(event: SatoriEvent) => void | Promise<void>>>()
private readyHandler?: (logins: SatoriReadyBody) => void | Promise<void>
constructor(config: SatoriClientConfig) {
this.config = config
}
async connect(): Promise<void> {
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<void> {
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<void> {
log.log(`Received event: ${event.type}`)
const handlers = this.eventHandlers.get(event.type)
if (handlers && handlers.size > 0) {
const promises: Promise<void>[] = []
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<void>[] = []
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<void> {
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>): 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>): 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>): 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
}
}
+90
View File
@@ -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<Database>(file)
const db = new Low<Database>(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<Channel[]> {
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 }
+100
View File
@@ -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)
+129
View File
@@ -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<string, SatoriMessage[]>
incomingMessages?: SatoriMessage[]
},
): Promise<Action | undefined> {
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<string, unknown>).think = false
}
const res = await generateText(req)
res.text = res.text.replace(/<think>[\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
}
}
+9
View File
@@ -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))()
}
@@ -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.
@@ -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.
+70
View File
@@ -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<string, SatoriMessage[]> // channelId -> messages
processedIds: Set<string>
processing: boolean
lastInteractedChannelIds: string[]
currentProcessingStartTime?: number
chats: Map<string, ChatContext>
}
export interface ChatContext {
channelId: string
platform: string
selfId: string
currentTask?: CancellablePromise<void>
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
+160
View File
@@ -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<T = any> {
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<string, any>
}
// 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<string, any>
}
// 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<T> {
data: T[]
next?: string
}
// Bidirectional paginated list
export interface SatoriBidiList<T> {
data: T[]
prev?: string
next?: string
}
+6
View File
@@ -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)
}
+18
View File
@@ -0,0 +1,18 @@
export interface CancellablePromise<T> {
promise: Promise<T>
cancel: () => void
}
export function cancellable<T>(promise: Promise<T>): CancellablePromise<T> {
let cancel: () => void
const wrappedPromise = new Promise<T>((resolve, reject) => {
cancel = () => reject(new Error('CANCELLED'))
promise.then(resolve).catch(reject)
})
return {
promise: wrappedPromise,
cancel: () => cancel?.(),
}
}
+20
View File
@@ -0,0 +1,20 @@
import { readFile } from 'node:fs/promises'
import { relativeOf } from './path'
export interface VelinModule {
render: <P>(data: P) => Promise<string>
}
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<P = undefined>(module: string, base: string): (data?: P) => Promise<string> {
return importVelin(module, base).render
}
+21
View File
@@ -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"
]
}