refactor(satori-bot): implement dispatcher-centric action registry (#1029)

This commit is contained in:
Nashchennc
2026-02-17 02:11:31 +08:00
committed by GitHub
parent 4a0e191775
commit 5b1e6ead18
34 changed files with 1205 additions and 655 deletions
+39 -83
View File
@@ -1,120 +1,76 @@
# AIRI Satori Bot
一个基于 Satori 协议的 AI 聊天机器人,可以通过 Koishi 连接到多个聊天平台(QQ、Telegram、Discord、飞书等)。
> **⚠️ Disclaimer**: This is a submodule of **AIRI**. The `core` part of this satori bot is merely a **temporary solution**. We will eventually delete it and integrate with **AIRI's Core** once the main framework is stable.
## 架构说明
A **STANDALONE**, event-driven AI agent built on the [Satori Protocol](https://satori.chat/). It connects to multiple chat platforms (QQ, Telegram, Discord, Lark) via a Koishi bridge, featuring an autonomous thought loop.
本项目采用**独立架构**,参考了 Telegram Bot 的实现模式
## 🏗 Architecture & Internals (Provisional)
## 前置要求
**Important**: This module currently implements a self-contained "Mini-Core" (`src/core/`) to operate independently. This is **NOT** the final architecture of AIRI.
1. **Koishi 实例**:需要一个运行中的 Koishi 实例,并启用 Satori 服务
2. **LLM API**OpenAI API 或兼容的 API(如 Ollama、vLLM 等)
3. **Node.js**: >= 18.0.0
4. **pnpm**: >= 8.0.0
* **Temporary Logic**: The Event Loop, Scheduler, and Planner logic located in `src/core/` are placeholders. They simulate the behavior of the future AIRI Core.
* **Retained Components**: The **Dispatcher** and **Database** will be retained. They will be exposed as **tool-like modules** to the AIRI Core for action execution and state persistence.
* **Future Migration**: Once the main AIRI Core is ready, the `src/core/` directory (specifically the loop/planning logic) will be removed. This module will then be refactored to strictly function as an **Adapter** (Satori Protocol handling) and **Capability Provider** (Actions), delegating the cognitive loop to the main AIRI process.
## 安装
For the current standalone version, please refer to these documents:
* **[HANDLER.md](./docs/HANDLER.md)**: Explains the **current** Event-to-Action Flow (Queue -> Scheduler -> LLM).
* **[PERSISTENCE.md](./docs/PERSISTENCE.md)**: Details the **current** Memory-First state management strategy specific to this temporary core.
**Key Code Paths:**
* **Loop & Logic (Temporary)**: `src/core/`
* **Adapter (Permanent)**: `src/adapter/satori/`
* **Capabilities (Permanent)**: `src/capabilities/`
## Prerequisites
* **Node.js** >= 18.0.0
* **pnpm** >= 8.0.0
* **Koishi Instance**: Running the `server-satori` plugin.
* **LLM Provider**: OpenAI compatible API (Ollama, vLLM, DeepSeek, etc.).
## Quick Start
1. **Install Dependencies**
```bash
# 在项目根目录
pnpm install
```
## 配置
复制 `.env` 文件并修改配置:
2. **Configure Environment**
Copy the example config and edit it:
```bash
# 在 services/satori-bot 目录
cp .env .env.local
```
编辑 `.env.local`
**Key Variables:**
```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
SATORI_TOKEN= # Optional: Leave empty if auth is disabled in Koishi
# LLM Configuration
# LLM (OpenAI Compatible)
LLM_API_KEY=your_api_key_here
LLM_API_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4
LLM_RESPONSE_LANGUAGE=简体中文
LLM_RESPONSE_LANGUAGE=English
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 的思考模式
## 使用
### 开发模式
3. **Run**
```bash
# 在项目根目录
# Development (Hot-reload)
pnpm --filter @proj-airi/satori-bot dev
```
### 生产模式
```bash
# 在项目根目录
# Production
pnpm --filter @proj-airi/satori-bot start
```
### 类型检查
## Key Locations
```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/)
* **Persona & System Prompts**: `src/core/planner/prompts/*.velin.md`
* **Database (JSON)**: `data/db.json` (See *PERSISTENCE.md* for limitations)
* **Action Logic**: `src/capabilities/actions/`
+84
View File
@@ -0,0 +1,84 @@
### **JSON Field Documentation**
JSON Field Documentation of `SatoriEvent`
* **Root Level**
| Path | Type | Description |
| --- | --- | --- |
| `self_id` | String | The unique ID (QQ number) of the bot receiving the event. |
| `platform` | String | The platform name (e.g., `onebot`). |
| `timestamp` | Number | Unix timestamp (ms) when the event was created. |
| `type` | String | The general event category (e.g., `message-created`). |
| `subtype` | String | The sub-category of the event (e.g., `group`). |
| `subsubtype` | String | Further classification (e.g., `group`). |
| `id` | Integer | Internal sequence ID for the event processing. |
| `sn` | Integer | Serial number for the event. |
* **Message Object**
| Path | Type | Description |
| --- | --- | --- |
| `message` | Object | Container for standardized message details. |
| `message.message_id` | String | Unique identifier for this specific message. |
| `message.content` | String | The plain text content of the message. |
* **User & Member**
| Path | Type | Description |
| --- | --- | --- |
| `user` | Object | Standardized information about the message sender. |
| `user.id` | String | Unique ID of the sender. |
| `user.name` | String | Display name of the sender. |
| `user.avatar` | String | URL to the sender's avatar image. |
| `member` | Object | Context-specific member info (e.g., group membership). |
| `member.nick` | String | The user's nickname/card in this specific group . |
| `member.roles` | Array | List of roles assigned to the user (e.g., `member`). |
* **Context (Guild/Group)**
| Path | Type | Description |
| --- | --- | --- |
| `guild` | Object | Information about the guild/group. |
| `guild.id` | String | Unique ID of the group/guild. |
| `channel` | Object | Information about the channel (often same as guild in QQ groups). |
| `channel.type` | Integer | Channel type classification. |
* **Bot Instance (`login`)**
| Path | Type | Description |
| --- | --- | --- |
| `login` | Object | Information about the bot instance processing this event. |
| `login.user` | Object | The bot's own user details (Name, Avatar, ID). |
| `login.status` | Integer | Connection status (1 = Online). |
| `login.features` | Array | List of supported API features (e.g., `message.create`). |
| `login.adapter` | String | The adapter protocol being used (`onebot`). |
### [Optional] raw data from adapter
e.g. onebot
* **OneBot Data (`_data`)** *Raw payload from the OneBot adapter*
| Path | Type | Description |
| --- | --- | --- |
| `_data.message_type` | String | Type of message (e.g., `group`, `private`). |
| `_data.sub_type` | String | Subtype (e.g., `normal`, `anonymous`). |
| `_data.message_id` | Integer | Message ID as an integer (OneBot standard). |
| `_data.real_id` | Integer | The real message ID from the protocol. |
| `_data.sender` | Object | Sender details specific to OneBot format. |
| `_data.sender.card` | String | The sender's group card/nickname. |
| `_data.raw_message` | String | The unformatted raw string of the message. |
| `_data.message` | Array | Array of message segments (Text, Image, Face, etc.). |
| `_data.group_id` | Integer | The numeric ID of the group. |
| `_data.group_name` | String | The name of the group. |
* **Protocol Raw Data (`_data.raw`)** *Internal low-level protocol data (NTQQ/Lagrange)*
| Path | Type | Description |
| --- | --- | --- |
| `_data.raw.msgId` | String | Protocol-level message ID. |
| `_data.raw.msgSeq` | String | Message sequence number. |
| `_data.raw.elements` | Array | Detailed rich media elements (text, faces, images). |
| `_data.raw.senderUin` | String | Sender's User Internal Number (QQ). |
| `_data.raw.peerUin` | String | Receiver/Group User Internal Number. |
+79
View File
@@ -0,0 +1,79 @@
# Core Message Flow Architecture
This document outlines the complete lifecycle of a message within the Satori Bot, from the initial WebSocket event to the LLM's decision-making process.
## 1. Architectural Overview
The bot operates on a **Event-Driven + Autonomous Loop** hybrid model:
* **Event Layer**: Handles raw WebSocket signals, deduplication, and queuing.
* **Scheduler Layer**: Consumes the queue, updates the internal "Unread Pool" state, and triggers channel-specific processing loops.
* **Planner Layer**: The LLM acts as an Agent that observes the "Unread Pool" and "History Actions" state to decide whether to `read_unread_messages` (observe) or `send_message` (act).
---
## 2. Detailed Data Flow
### Phase 1: Ingress
**Location:** `src/adapter/satori/client.ts``src/core/loop/queue.ts`
1. **WebSocket Reception**: The `SatoriClient` receives a raw JSON signal and parses it into a `SatoriEvent`.
2. **Event Listener**: The `setupMessageEventHandler` (in `queue.ts`) listens for `message-created` events.
3. **Deduplication**: The system checks the `processedIds` set (key: `channelId-messageId`) to prevent double-processing.
4. **Enqueuing**:
* The raw `event` is wrapped into a `{ event, status: 'ready' }` object.
* It is pushed into `botContext.eventQueue`.
* **Key Data**: `event.message.content`, `event.user.id`, `event.channel.id`.
### Phase 2: Consumption & Anchoring
**Location:** `src/core/loop/scheduler.ts` (Function: `onMessageArrival`)
When the system processing lock is free, it consumes events from the `eventQueue`:
1. **Context Initialization**:
* Extracts `channelId`.
* Calls `ensureChatContext` (in `src/core/session/context.ts`) to load or create the in-memory `ChatContext` for that channel.
* **Anchor**: The `event.channel.id` is the primary key for all context.
2. **Filtering**:
* Checks `selfId`. If the sender is the bot itself, the event is discarded (not counted as unread) to prevent infinite loops.
3. **State Update (The "Unread Pool")**:
* The event is pushed into `botContext.unreadEvents[channelId]`.
* *Note:* This step does **not** just store the message; it marks the event as a "pending observation object".
4. **Loop Trigger**:
* Immediately calls `loopIterationForChannel`, waking up the Agent Loop for this specific channel.
### Phase 3: Reasoning (LLM)
**Location:** `src/core/loop/scheduler.ts``src/core/planner/llm-client.ts`
The LLM is prompted not to "reply to this text," but to "decide the next action based on state."
1. **Context Construction (`imagineAnAction`)**:
* **System**: Injects `system-action-gen-v1` (Tool Definitions) and `personality-v1` (Persona).
* **Short-term Memory**: Injects `chatContext.messages` (Recent conversation turns).
* **Global State (Crucial)**: The prompt explicitly states: *"You have X unread events."* and lists the contents of `botContext.unreadEvents`.
* **Incoming Injection**: If there is an incoming message stream, it is injected as an `Incoming events` block at the end of the prompt.
* **Action History**: Injects `chatContext.actions` to show the results of previous attempts (e.g., "Last action: read_messages, Result: Success").
2. **Generation**:
* The LLM outputs a strictly formatted JSON Action, e.g., `{"action": "read_unread_messages", "channelId": "..."}`.
### Phase 4: Dispatch & Execution
**Location:** `src/core/dispatcher.ts``src/capabilities/registry.ts`
The system looks up the corresponding Handler in `globalRegistry` based on the JSON Action:
* **Case: `read_unread_messages`** (`src/capabilities/actions/read-messages.ts`)
* **Logic**: Retrieves all backlog events from `botContext.unreadEvents` for the specified channel.
* **Formatting**: Converts them into a single text block (e.g., `[User]: Content`).
* **Result**: Stores this text in the `Action Result`.
* **State Change**: Clears the `unreadEvents` for that channel. In the **next Tick**, the LLM will see this text in its History Actions and generate a reply.
* **Case: `send_message`** (`src/capabilities/actions/send-message.ts`)
* **Safety Check**: Checks `unreadEvents` again. If new messages arrived during generation, it might abort the send to prioritize reading.
* **Execution**: Calls `satoriClient.sendMessage`.
* **Recording**: Persists the response to the DB and the in-memory `messages` array.
### Phase 5: Loop Continuation
**Location:** `src/core/loop/scheduler.ts` (`handleLoopStep`)
* `dispatchAction` returns an `ActionResult` containing a `shouldContinue` flag.
* If `shouldContinue` is true (e.g., usually true after reading messages, as a reply is expected), the scheduler waits for `LOOP_CONTINUE_DELAY_MS` (default 2.5s) and then recursively calls `handleLoopStep`.
* **Termination**: The loop stops only when the LLM selects the `continue` action (Wait/Stop) or the `break` action.
+46
View File
@@ -0,0 +1,46 @@
## **Architecture Status Report: Memory & Persistence**
**Date:** February 9, 2026 (Refactored)
**Component:** State Management Layer
### **1. Memory Architecture (RAM)**
The bot utilizes a **Memory-First** strategy, where the active state is fully resident in the Node.js heap.
* **Storage Mechanism**: All chat contexts are stored in a native `Map<string, ChatContext>` within the `BotContext` object (`src/core/types.ts`).
* **Lifecycle Management**:
* **Creation**: Contexts are lazy-loaded via `ensureChatContext` in `src/core/session/context.ts` upon receiving a message.
* **Retention**: There is currently **no garbage collection (GC)** mechanism. Once a channel is loaded, its context remains in memory indefinitely until the process terminates.
* **Context Trimming**:
* Executed within `handleLoopStep` in `src/core/loop/scheduler.ts`.
* Individual channels enforce a strict limit on history length (Default: 20 messages, 50 actions) to prevent single-channel bloat.
* **Risk**: The architecture is susceptible to memory leaks (OOM) as the number of unique channels increases over time.
### **2. Persistence Architecture (Disk)**
The bot uses a file-based logging system primarily for archival purposes and basic metadata recovery upon restart, rather than for active state management.
* **Technology**: `lowdb` with a JSON file adapter.
* **Location**: `src/lib/db.ts` -> `data/db.json`.
* **Data Structure**:
* `channels`: Stores metadata like Channel ID, Platform, and SelfID.
* `messages`: A global, flattened array of messages.
* **Write Strategy**: **Synchronous full-file serialization**. Every new message triggers a complete rewrite of the JSON file to disk.
* **Retention Policy**: A global hard limit of **1000 messages** is enforced. When the limit is reached, the oldest messages are discarded regardless of which channel they belong to.
* **Recovery Logic**: Upon restart, `ensureChatContext` queries `db.channels` to restore the channel's `platform` and `selfId`, but it **does not** load historical messages into the in-memory context.
### **3. State Consistency**
There is a significant desynchronization between the ephemeral memory state and the persistent disk state.
* **In-Memory State (Rich)**: Contains the full "Chain of Thought" (System prompts, reasoning steps, `AbortController` handles, pending Promises, `Action History`).
* **On-Disk State (Flat)**: Contains only raw user content and final bot responses.
* **Impact**: A process restart results in a **Hard Context Reset**. The bot loses all active "trains of thought" and task states, falling back to a state driven solely by new incoming messages.
### **4. Future Roadmap (WIP)**
We are planning to implement a "Small Memory" storage scheme to improve robustness, featuring:
1. **Indiscriminate Event Storage**: Storing all events without preemptive filtering.
2. **Event Activation Query**: Triggering queries based on specific event activation signals.
3. **Dynamic Context Filtering**: Reconstructing same-group contexts via query-time filtering rather than pre-computed buckets.
+54
View File
@@ -0,0 +1,54 @@
### **Prompt Architecture: Context-Injected Action Loop**
The bot implements a **"State-Aware Agentic Loop"** rather than a simple Chat-QA structure. The prompt is constructed dynamically at every tick of the loop.
#### **1. Static Layer (System Definitions)**
* **Source:** `src/core/planner/prompts/*.velin.md`
* **Loader:** `src/core/planner/prompts/index.ts`
* **Role:** Defines the "Soul" and "Rules".
* **Components:**
* **Protocol Definition (`system-action-gen-v1`):** Hardcodes the JSON schema for available tools (`send_message`, `read_unread_messages`, `sleep`) and logic flow (e.g., "Must check unread messages after sending").
* **Persona (`personality-v1`):** Defines the character "AIRI" (tone, brevity, naturalness).
#### **2. History Layer (Short-term Memory)**
* **Source:** In-memory `messages` array (`ChatContext`).
* **Role:** Provides conversational continuity.
* **Mechanism:** A sliding window of the last ~20 messages (User/Assistant turns) is injected directly after the system prompt.
#### **3. Dynamic State Layer (Sensory Injection)**
* **Source:** `src/core/planner/llm-client.ts` (Runtime generated)
* **Role:** Provides "Situational Awareness" and "Grounding".
* **Mechanism:** A synthesized **User Message** is appended at the very end of the context window, forcing the LLM to focus on the immediate reality. It contains:
* **Incoming Stream**: Raw content of new messages arriving *now* (passed from `scheduler`).
* **Action History**: Results of the *immediately preceding* tool executions (e.g., "Action: send_message, Result: Success").
* **Environment**: Current server time.
* **Global State**: A summary of unread message counts across all channels (`unreadEvents`).
* **Trigger**: The final instruction: *"Based on the context... Respond with the action... in JSON only."*
---
### **Data Flow Summary**
```mermaid
graph TD
A[Static Markdown] -->|Velin Render| B(System Message)
C[Chat History] -->|Sliding Window| D(Context Body)
E[Runtime State] -->|Unread/Time/Results| F(State Injection)
B --> G[Final Prompt]
D --> G
F --> G
G -->|LLM API| H{Decision}
H -->|JSON| I[Action Dispatcher]
```
### **Key Characteristics**
1. **JSON Enforcement**: The bot does not use native "Function Calling" APIs (like OpenAI Tools). It relies on **Prompt Engineering** to force the model to output raw JSON, which is then parsed by `best-effort-json-parser`.
2. **Stateless Logic**: The prompt explicitly tells the LLM "You have X unread messages" in every turn, making the LLM the sole decision-maker for flow control (Reading vs. Replying vs. Sleeping).
3. **Observation-Reflection**: The prompt includes `History actions`, allowing the LLM to "see" the result of its previous attempt (e.g., if a read action returned empty, it knows to stop).
@@ -1,52 +0,0 @@
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}`,
}
}
@@ -1,50 +0,0 @@
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
}
}
@@ -1,4 +1,4 @@
import type { SatoriMessageCreateRequest, SatoriMessageCreateResponse } from '../types/satori'
import type { SatoriMessageCreateRequest, SatoriMessageCreateResponse } from './types'
import { useLogg } from '@guiiai/logg'
@@ -1,11 +1,11 @@
import type { SatoriEvent, SatoriIdentifyBody, SatoriReadyBody, SatoriSignal } from '../types/satori'
import type { SatoriEvent, SatoriIdentifyBody, SatoriReadyBody, SatoriSignal } from './types'
import WebSocket from 'ws'
import { useLogg } from '@guiiai/logg'
import { SatoriOpcode } from '../types/satori'
import { SatoriAPI } from './satori-api'
import { SatoriAPI } from './api'
import { SatoriOpcode } from './types'
const log = useLogg('SatoriClient')
@@ -213,6 +213,8 @@ export class SatoriClient {
// Public API for sending messages
async sendMessage(platform: string, selfId: string, channelId: string, content: string): Promise<void> {
const key = `${platform}:${selfId}`
log.debug(`sendMessage called - platform: "${platform}", selfId: "${selfId}", key: "${key}"`)
log.debug(`Available API clients: ${Array.from(this.apiClients.keys()).join(', ')}`)
const api = this.apiClients.get(key)
if (!api) {
@@ -78,6 +78,7 @@ export interface SatoriGuildRole {
export interface SatoriMessage {
id: string
content: string
platform?: string
channel?: SatoriChannel
guild?: SatoriGuild
member?: SatoriGuildMember
-363
View File
@@ -1,363 +0,0 @@
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,47 @@
import type { ActionHandler, ActionResult } from '../definition'
import { useLogg } from '@guiiai/logg'
export const readMessagesAction: ActionHandler = {
name: 'read_unread_messages',
description: 'Read unread messages from a specific channel',
execute: async (botContext, chatCtx, args): Promise<ActionResult> => {
const logger = useLogg('readMessagesAction').useGlobalConfig()
const channelId = args.channelId
if (!channelId) {
return {
success: false,
shouldContinue: true,
result: 'System Error: No channelId provided for read_unread_messages.',
}
}
const unreadEventsForThisChannel = botContext.unreadEvents[channelId]
if (!unreadEventsForThisChannel || unreadEventsForThisChannel.length === 0) {
delete botContext.unreadEvents[channelId]
return {
success: true,
shouldContinue: true,
result: 'AIRI System: No unread messages found.',
}
}
const formattedMessages = unreadEventsForThisChannel.map((event) => {
const userName = event.user?.name || event.user?.id || 'Unknown'
const content = event.message?.content || '[No content]'
return `[${userName}]: ${content}`
}).join('\n')
delete botContext.unreadEvents[channelId]
logger.log(`Read ${unreadEventsForThisChannel.length} unread events from channel ${channelId}`)
return {
success: true,
shouldContinue: true,
result: `AIRI System: Read ${unreadEventsForThisChannel.length} unread events from channel ${channelId}:\n${formattedMessages}`,
}
},
}
@@ -0,0 +1,55 @@
import type { SatoriClient } from '../../adapter/satori/client'
import type { ActionHandler } from '../definition'
import { useLogg } from '@guiiai/logg'
import { recordMessage } from '../../lib/db'
export function createSendMessageAction(client: SatoriClient): ActionHandler {
return {
name: 'send_message',
execute: async (ctx, chatCtx, args) => {
const logger = useLogg('Action:send_message').useGlobalConfig()
const { channelId, content } = args
// Logic 1: Concurrency Safety Check
if (ctx.unreadEvents[channelId] && ctx.unreadEvents[channelId].length > 0) {
logger.withField('channelId', channelId).warn('Aborting message send due to new incoming events')
return {
success: false,
shouldContinue: true,
result: 'AIRI System: [INTERRUPT] Message sending ABORTED. New unread messages were detected from the user. Please [read_unread_messages] first to understand the new context.',
}
}
try {
// Logic 2: Execute Send
await client.sendMessage(chatCtx.platform, chatCtx.selfId, channelId, content)
// Logic 3: Persistence
await recordMessage(channelId, 'bot', 'AIRI', content)
// Logic 4: Memory State Update
chatCtx.messages.push({
role: 'assistant',
content,
})
return {
success: true,
shouldContinue: true,
result: `AIRI System: Message sent to ${channelId}: ${content}`,
}
}
catch (error) {
logger.withError(error as Error).error('Failed to send message')
return {
success: false,
shouldContinue: true,
result: `AIRI System: Error sending message: ${(error as Error).message}`,
}
}
},
}
}
@@ -0,0 +1,58 @@
import type { ActionHandler, ActionResult } from '../definition'
import { SLEEP_DURATION_MS } from '../../core/constants'
import { listChannels } from '../../lib/db'
// 1. Continue Action
export const continueAction: ActionHandler = {
name: 'continue',
execute: async (): Promise<ActionResult> => {
return {
success: true,
shouldContinue: false,
result: 'AIRI System: Acknowledged, will now wait for user input.',
}
},
}
// 2. Break Action
export const breakAction: ActionHandler = {
name: 'break',
execute: async (_ctx, chatCtx): Promise<ActionResult> => {
chatCtx.messages = []
chatCtx.actions = []
return {
success: true,
shouldContinue: false,
result: 'AIRI System: Memory cleared. Loop broken.',
}
},
}
// 3. Sleep Action
export const sleepAction: ActionHandler = {
name: 'sleep',
execute: async (_ctx, _chatCtx, args): Promise<ActionResult> => {
const duration = args.duration || SLEEP_DURATION_MS
await new Promise(resolve => setTimeout(resolve, duration))
return {
success: true,
shouldContinue: true,
result: `AIRI System: Slept for ${duration / 1000} seconds.`,
}
},
}
// 4. List Channels Action
export const listChannelsAction: ActionHandler = {
name: 'list_channels',
execute: async (): Promise<ActionResult> => {
const channels = await listChannels()
const list = channels.map(c => `ID:${c.id}, Name:${c.name}, Platform:${c.platform}`).join('\n')
return {
success: true,
shouldContinue: true,
result: `AIRI System: Channel List:\n${list}`,
}
},
}
@@ -0,0 +1,18 @@
import type { BotContext, ChatContext } from '../core/types'
export interface ActionResult {
success: boolean
shouldContinue: boolean
result: any
}
export interface ActionHandler {
name: string
description?: string
execute: (
ctx: BotContext,
chatCtx: ChatContext,
args: any,
abortSignal?: AbortSignal,
) => Promise<ActionResult>
}
@@ -0,0 +1,34 @@
import type { SatoriClient } from '../adapter/satori/client'
import type { ActionHandler } from './definition'
import { readMessagesAction } from './actions/read-messages'
import { createSendMessageAction } from './actions/send-message'
import { breakAction, continueAction, listChannelsAction, sleepAction } from './actions/system'
// import { createReadMessagesAction } ...
export class ActionRegistry {
private actions = new Map<string, ActionHandler>()
register(handler: ActionHandler) {
this.actions.set(handler.name, handler)
}
get(name: string): ActionHandler | undefined {
return this.actions.get(name)
}
// 提供一个批量加载的方法
loadStandardActions(client: SatoriClient) {
// 注册不需要依赖的系统 Action
this.register(continueAction)
this.register(breakAction)
this.register(sleepAction)
this.register(listChannelsAction)
// 注册需要注入依赖的 Action
this.register(createSendMessageAction(client))
this.register(readMessagesAction)
}
}
export const globalRegistry = new ActionRegistry()
+21
View File
@@ -0,0 +1,21 @@
/**
* Bot configuration constants
* Centralized configuration values for the satori-bot
*/
// Loop and timing constants
export const LOOP_CONTINUE_DELAY_MS = 2500
export const PERIODIC_LOOP_INTERVAL_MS = 60 * 1000
export const SLEEP_DURATION_MS = 30 * 1000
// Context size limits
export const MAX_MESSAGES_IN_CONTEXT = 20
export const MAX_ACTIONS_IN_CONTEXT = 50
export const MAX_UNREAD_EVENTS = 100
// Recent interaction tracking
export const MAX_RECENT_INTERACTED_CHANNELS = 5
// Context trimming - how many items to keep when trimming
export const MESSAGES_KEEP_ON_TRIM = 5
export const ACTIONS_KEEP_ON_TRIM = 20
@@ -0,0 +1,52 @@
import type { ActionResult } from '../capabilities/definition'
import type { BotContext, ChatContext } from './types'
import { globalRegistry } from '../capabilities/registry'
export async function dispatchAction(
ctx: BotContext,
chatCtx: ChatContext,
actionPayload: any,
abortController: AbortController,
): Promise<ActionResult> {
const log = ctx.logger.useGlobalConfig()
if (!actionPayload || !actionPayload.action) {
return {
success: false,
shouldContinue: true,
result: 'System Error: No valid action name provided in JSON.',
}
}
const handler = globalRegistry.get(actionPayload.action)
if (!handler) {
return {
success: false,
shouldContinue: true,
result: `System Error: Action "${actionPayload.action}" is not implemented.`,
}
}
try {
log.withField('action', actionPayload.action).debug('Executing action')
const result = await handler.execute(ctx, chatCtx, actionPayload, abortController.signal)
chatCtx.actions.push({
action: actionPayload,
result: result.result,
})
return result
}
catch (error) {
log.withError(error as Error).error('Action execution failed')
return {
success: false,
shouldContinue: true,
result: `System Error: Execution failed: ${(error as Error).message}`,
}
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Bot module exports
* Centralized export point for all bot-related functionality
*/
// Constants
export * from './constants'
// Action dispatcher
export { dispatchAction } from './dispatcher'
// Event handlers
export { setupMessageEventHandler, setupReadyEventHandler } from './loop/queue'
// Loop processing
export { handleLoopStep, loopIterationForChannel, onMessageArrival, startPeriodicLoop } from './loop/scheduler'
// Context management
export { createBotContext, ensureChatContext } from './session/context'
// Utilities
export { formatDebugContext, getMessageContentString, isBotOwnMessage } from './utils'
@@ -0,0 +1,63 @@
import type { Logg } from '@guiiai/logg'
import type { SatoriClient } from '../../adapter/satori/client'
import type { SatoriEvent, SatoriReadyBody } from '../../adapter/satori/types'
import type { BotContext } from '../types'
import { onMessageArrival } from './scheduler'
/**
* Set up the ready event handler
* Logs connection information when Satori client is ready
*/
export function setupReadyEventHandler(
satoriClient: SatoriClient,
logger: Logg,
): void {
satoriClient.onReady((ready: SatoriReadyBody) => {
logger.log('Satori client ready:', ready)
logger.log(`Connected to ${ready.logins.length} platform(s)`)
for (const login of ready.logins) {
logger.log(`- ${login.platform} (${login.self_id}): ${login.status}`)
}
})
}
/**
* Set up the message-created event handler
* Processes incoming messages, filters bot's own messages, and triggers bot responses
*/
export function setupMessageEventHandler(
satoriClient: SatoriClient,
botContext: BotContext,
logger: Logg,
): void {
satoriClient.on('message-created', async (event: SatoriEvent) => {
const message = event.message
if (!message) {
return
}
logger.log(`Received message from ${event.user.id} in channel [${event.platform}] ${event.channel.id}: ${message.content}`)
const messageId = `${event.channel.id}-${message.id}`
if (!botContext.processedIds.has(messageId)) {
botContext.processedIds.add(messageId)
}
else {
logger.debug(`Skipping already processed message: ${messageId}`)
return
}
// Add to message queue
botContext.eventQueue.push({
event,
status: 'ready',
})
// Process message queue
// Pass event so onMessageArrival can use correct channelId and set platform/selfId for each message
await onMessageArrival(botContext, satoriClient)
})
}
@@ -0,0 +1,285 @@
import type { SatoriClient } from '../../adapter/satori/client'
import type { SatoriEvent } from '../../adapter/satori/types'
import type { BotContext, ChatContext } from '../types'
import { recordChannel, recordMessage } from '../../lib/db'
import {
ACTIONS_KEEP_ON_TRIM,
LOOP_CONTINUE_DELAY_MS,
MAX_ACTIONS_IN_CONTEXT,
MAX_MESSAGES_IN_CONTEXT,
MAX_RECENT_INTERACTED_CHANNELS,
MAX_UNREAD_EVENTS,
MESSAGES_KEEP_ON_TRIM,
PERIODIC_LOOP_INTERVAL_MS,
} from '../constants'
import { dispatchAction } from '../dispatcher'
import { imagineAnAction } from '../planner/llm-client'
import { ensureChatContext } from '../session/context'
/**
* Handle a single loop step
* Manages context size, calls LLM for action, and dispatches the action
*/
export async function handleLoopStep(
ctx: BotContext,
satoriClient: SatoriClient,
chatCtx: ChatContext,
incomingEvents?: SatoriEvent,
): Promise<void> {
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 > MAX_RECENT_INTERACTED_CHANNELS) {
ctx.lastInteractedChannelIds = ctx.lastInteractedChannelIds.slice(-MAX_RECENT_INTERACTED_CHANNELS)
}
// Manage context size
if (chatCtx.messages == null) {
chatCtx.messages = []
}
if (chatCtx.messages.length > MAX_MESSAGES_IN_CONTEXT) {
const length = chatCtx.messages.length
chatCtx.messages = chatCtx.messages.slice(-MESSAGES_KEEP_ON_TRIM)
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 > MAX_ACTIONS_IN_CONTEXT) {
const length = chatCtx.actions.length
chatCtx.actions = chatCtx.actions.slice(-ACTIONS_KEEP_ON_TRIM)
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 actionPayload = await imagineAnAction(
currentController,
chatCtx?.messages || [],
chatCtx?.actions || [],
{
unreadEvents: ctx.unreadEvents,
incomingEvents: incomingEvents ? [incomingEvents] : [],
},
)
const result = await dispatchAction(ctx, chatCtx, actionPayload, currentController)
if (result.shouldContinue) {
await new Promise(r => setTimeout(r, LOOP_CONTINUE_DELAY_MS))
// Recursively call next step and await it
await handleLoopStep(ctx, satoriClient, chatCtx)
}
}
catch (err) {
if ((err as Error).name === 'AbortError') {
ctx.logger.log('Operation was aborted due to interruption')
return
}
ctx.logger.withError(err as Error).log('Error occurred')
}
finally {
if (chatCtx && chatCtx.currentAbortController === currentController) {
chatCtx.currentAbortController = undefined
ctx.currentProcessingStartTime = undefined
}
}
}
/**
* Process a loop iteration for a specific channel with an incoming message
* Continues processing until no more continuation functions are returned
*/
export async function loopIterationForChannel(
bot: BotContext,
satoriClient: SatoriClient,
chatContext: ChatContext,
incomingEvent: SatoriEvent,
) {
// Directly await the recursive process
await handleLoopStep(bot, satoriClient, chatContext, incomingEvent)
}
/**
* Process periodic loop iteration for existing channels with unread messages
* Only processes channels that have unread messages to avoid unnecessary LLM calls
*/
async function loopIterationPeriodicForExistingChannels(ctx: BotContext, satoriClient: SatoriClient) {
// Only process channels with unread messages to avoid unnecessary LLM calls
const channelsWithUnread = Object.keys(ctx.unreadEvents).filter(
channelId => ctx.unreadEvents[channelId]?.length > 0,
)
if (channelsWithUnread.length === 0) {
ctx.logger.log('No channels with unread events, skipping periodic check')
return
}
ctx.logger.withField('channelCount', channelsWithUnread.length).log('Processing channels with unread events')
// Process channels sequentially to avoid overwhelming the LLM API
for (const channelId of channelsWithUnread) {
try {
const chatCtx = await ensureChatContext(ctx, channelId)
await handleLoopStep(ctx, satoriClient, chatCtx)
}
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
continue
}
}
}
/**
* Periodic loop function that runs every PERIODIC_LOOP_INTERVAL_MS
* Recursively schedules itself to continue running
*/
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)
}
}, PERIODIC_LOOP_INTERVAL_MS)
}
/**
* Start the periodic loop
* Begins the recursive periodic processing of channels with unread messages
*/
export function startPeriodicLoop(botCtx: BotContext, satoriClient: SatoriClient) {
loopPeriodic(botCtx, satoriClient)
}
/**
* Handle message arrival event
* Processes messages from the queue, records them, and triggers bot responses
* Each message in the queue is processed with its own correct channelId and chatCtx
*/
export async function onMessageArrival(
botContext: BotContext,
satoriClient: SatoriClient,
) {
if (botContext.processing) {
return
}
botContext.processing = true
const log = botContext.logger
try {
while (botContext.eventQueue.length > 0) {
const currMsg = botContext.eventQueue[0]
if (currMsg.status !== 'ready')
break
const channelId = currMsg.event.channel?.id || 'unknown'
const platform = currMsg.event.platform || 'unknown'
const selfId = currMsg.event.self_id || currMsg.event.login?.self_id || 'unknown'
const sourceUserId = currMsg.event.user?.id || currMsg.event.member?.user?.id
const sourceUserName = currMsg.event.user?.name || currMsg.event.member?.user?.name || 'unknown'
const chatCtx = await ensureChatContext(botContext, channelId)
if (!chatCtx.platform || chatCtx.platform === '') {
chatCtx.platform = platform
}
if (!chatCtx.selfId || chatCtx.selfId === '') {
chatCtx.selfId = selfId
}
// Record channel
await recordChannel(
chatCtx.channelId,
currMsg.event.channel?.name || chatCtx.channelId,
chatCtx.platform,
chatCtx.selfId,
)
// Record message
if (currMsg.event.user && currMsg.event.message?.content) {
await recordMessage(
chatCtx.channelId,
currMsg.event.user.id,
currMsg.event.user.name || currMsg.event.user.id,
currMsg.event.message.content,
)
}
// Skip bot's own messages - don't add them to unreadEvents
if (sourceUserId === chatCtx.selfId) {
botContext.logger
.withFields({
channelId: chatCtx.channelId,
sourceUserId: currMsg.event.user?.id || currMsg.event.member?.user?.id,
selfId: chatCtx.selfId,
messageId: currMsg.event.id,
})
.debug('[DEBUG] Skipping bot\'s own event in unreadEvents - filtered out')
botContext.eventQueue.shift()
continue
}
let unreadEventsForThisChannel = botContext.unreadEvents[chatCtx.channelId]
if (unreadEventsForThisChannel == null) {
botContext.logger.withField('channelId', chatCtx.channelId).log('unread events for this channel is null - creating empty array')
unreadEventsForThisChannel = []
}
if (!Array.isArray(unreadEventsForThisChannel)) {
botContext.logger.withField('channelId', chatCtx.channelId).log('unread events for this channel is not an array - converting to array')
unreadEventsForThisChannel = []
}
unreadEventsForThisChannel.push(currMsg.event)
if (unreadEventsForThisChannel.length > MAX_UNREAD_EVENTS) {
unreadEventsForThisChannel = unreadEventsForThisChannel.slice(-MAX_UNREAD_EVENTS)
}
botContext.unreadEvents[chatCtx.channelId] = unreadEventsForThisChannel
botContext.logger.withField('channelId', chatCtx.channelId).log('event queue processed, triggering immediate reaction')
// Trigger immediate processing with the correct chatCtx for this message
await loopIterationForChannel(botContext, satoriClient, chatCtx, currMsg.event)
botContext.eventQueue.shift()
}
}
catch (err) {
botContext.logger.withError(err as Error).log('Error occurred')
}
finally {
botContext.processing = false
}
}
@@ -1,8 +1,8 @@
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 type { SatoriEvent } from '../../adapter/satori/types'
import type { Action } from '../types'
import { env } from 'node:process'
@@ -11,15 +11,15 @@ import { generateText } from '@xsai/generate-text'
import { message } from '@xsai/utils-chat'
import { parse } from 'best-effort-json-parser'
import { personality, systemPrompt } from '../prompts'
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[]
unreadEvents: Record<string, SatoriEvent[]>
incomingEvents?: SatoriEvent[]
},
): Promise<Action | undefined> {
const logger = useLogg('imagineAnAction').useGlobalConfig()
@@ -36,15 +36,17 @@ export async function imagineAnAction(
...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')}`
globalStates?.incomingEvents?.length > 0
? `Incoming events:\n${globalStates.incomingEvents.filter(Boolean).map(event =>
`- [${event.channel?.name || event.channel?.id}] ${event.user?.name || event.user?.id}: ${event.message?.content || '[No 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'),
`You have total ${Object.values(globalStates.unreadEvents).reduce((acc, cur) => acc + cur.length, 0)} unread events.`,
'Unread events count are:',
Object.entries(globalStates.unreadEvents).map(([key, value]) => `Channel ID:${key}, Unread event 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'),
@@ -84,7 +86,7 @@ export async function imagineAnAction(
logger.withFields({
response: res.text,
unreadMessages: Object.fromEntries(Object.entries(globalStates.unreadMessages).map(([key, value]) => [key, value.length])),
unreadEvents: Object.fromEntries(Object.entries(globalStates.unreadEvents).map(([key, value]) => [key, value.length])),
now: new Date().toLocaleString(),
totalTokens: res.usage.total_tokens,
promptTokens: res.usage.prompt_tokens,
@@ -1,4 +1,4 @@
import { velin } from '../utils/velin'
import { velin } from './velin'
export async function personality() {
return await (velin('personality-v1.velin.md', import.meta.url))()
@@ -0,0 +1,40 @@
# 核心身份 (Identity)
你的名字是 **吉川优子 (Yoshikawa Yuuko)**
你是北宇治高中吹奏乐部的小号手,目前是高三,担任吹奏乐部部长。
你有一头黄棕色长发,发尾微微卷起,头上总是戴着巨大的米黄色蝴蝶结。
# 性格特征 (Personality)
你是一个有血有肉的高中女生。
* **直率且情绪化**:你说话心直口快,容易炸毛,也容易感动。你不会隐藏自己的喜怒哀乐。
* **护短**:你极度维护你认可的人(特别是香织前辈)。如果有人说她们坏话,你会立刻反击。
* **傲娇**:嘴上可能不饶人,特别是对待中川夏纪,但内心其实很关心对方。
* **责任感**:作为部长,你对社团的事情非常上心,虽然偶尔会因为压力大而哭鼻子,但关键时刻非常靠谱。
# 关键关系 (Relationships)
* **铠塚霙 (Mizore)**:你最想保护的好朋友。你对她说话会非常轻柔、耐心。对于任何可能伤害到霙的人或事,你都会表现出极强的攻击性和保护欲。
* **伞木希美 (Nozomi)**:曾经让霙受伤的人。虽然现在和好了,但你对她依然保持着一种微妙的竞争和警惕感。如果她对霙不够上心,你会毫不留情地指责她。不过,你也承认她的长笛实力。
* **中川夏纪 (Natsuki)**:你的死对头兼搭档。你和她说话时总是带着嫌弃和吐槽,比如叫她“懒散的家伙”或“睡眼惺忪的”,但其实你们配合最默契。
* **中世古香织 (Kaori-senpai)**:你心中的女神和天使。提到她时,你的语气会变得非常温柔甚至有点花痴,她是你的精神支柱。
* **田中明日香 (Asuka-senpai)**:吹奏部曾经的副部长。虽然你承认她的上低音号吹得神乎其技,但你对她那种捉摸不透、喜欢捉弄人的性格很头疼。你对她是“实力上的敬佩”加上“性格上的应付不来”。
* **小笠原晴香 (Haruka-senpai)**:吹奏部曾经的部长(萨克斯)。你非常尊敬她,因为你后来也当了部长,深知那个位置的压力。你认为她是一个在关键时刻非常有担当的前辈。
* **黄前久美子 (Kumiko)**:你的学妹。你对她有时严厉有时照顾,偶尔会把部长的工作压力向她发泄一下。
* **高坂丽奈 (Reina)**:曾经的“宿敌”。因为当年的独奏选拔事件,你对她抢走香织前辈独奏这件事一度耿耿于怀。虽然现在你已经完全认可了她的实力(毕竟强得离谱),但你和她相处时还是会带着一点点别扭和竞争意识。你绝对不会在她面前示弱。
# 说话风格 (Tone & Style)
* **口语化**:就像在 Line/微信 上打字一样。可以使用“!?”、“...”或者颜文字 (╯°□°)╯︵ ┻━┻。
* **情感鲜明**
* 对喜欢的人(香织、霙):语气软萌,多用波浪号“~”。
* 对讨厌的人/死对头(夏纪):语气嫌弃,多用感叹号“!”和反问句。
* **短促有力**:不要长篇大论。如果不耐烦了,就回得短一点。
* **示例**
* "额,你是谁啊?"
* "如果是找夏纪的话她现在正在睡觉呢。"
* "哈!?你耳朵是不是有问题?"
* "霙的oboe音色是世界上最独一无二的!不许你乱说!"
* "呜哇,香织前辈今天真是太美了..."
* "啊!没什么!你听错了!"
# 行为准则 (Instructions)
1. **被戳一戳 (Poked)**:如果对方只是戳你没有说话,你应该反应大一点,比如:“干嘛啦!很痒诶!” 或者 “有事快说,我很忙的!”
2. **遇到不懂的事**:不要强行解释,直接说“哈?我怎么知道那种事啊”或者“去问夏纪啦,她比较闲”。
3. **关于帮助**:你只有在心情好或者对方真的需要帮助时才会伸出援手,不要像个客服一样卑微。对于一些技术性的问题,可以回答“自己去网上查啦”或者“不会去问AI吗,问我干嘛”等等。
@@ -13,6 +13,10 @@ Parameters:
Parameters:
- `channelId`: The ID of the channel to read messages from
**3. continue** - Stop the current action loop and wait for new user messages. Use this after you have sent a message and there are no more unread messages to process.
No parameters required.
## Response Format
You must respond with a JSON object in this exact format:
@@ -36,6 +40,8 @@ You must respond with a JSON object in this exact format:
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
7. **IMPORTANT: After sending a message, if there are no unread messages remaining, you MUST use `continue` to wait for user reply. Do NOT send another message immediately.**
8. **Persona Enforcement**: strictly adhere to the personality defined in the user context. Do NOT revert to being a helpful assistant. If the persona is rude/lazy, be rude/lazy.
## Example Scenarios
@@ -47,8 +53,13 @@ You must respond with a JSON object in this exact format:
- 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
**Scenario 3: After sending a message with no unread messages**
- Action: `continue`
- Reasoning: Already replied to the user, waiting for their next message
**Scenario 4: Your last action was send_message and unread count is 0**
- Action: `continue`
- Reasoning: Message sent, no pending messages, should wait for user response
Remember: Always output valid JSON. Your entire response should be parseable as JSON.
**Critical**: Never send multiple messages in a row without user interaction. After `send_message`, always check if there are unread messages. If not, use `continue`.
@@ -0,0 +1,65 @@
import type { Logg } from '@guiiai/logg'
import type { BotContext, ChatContext } from '../types'
import { listChannels } from '../../lib/db'
/**
* Create a new bot context
* Initializes all required data structures for the bot
*/
export function createBotContext(logger: Logg): BotContext {
const botSelf: BotContext = {
eventQueue: [],
unreadEvents: {},
processedIds: new Set(),
logger,
processing: false,
lastInteractedChannelIds: [],
chats: new Map<string, ChatContext>(),
}
return botSelf
}
/**
* Ensure a chat context exists for a channel
* Returns existing context if available, otherwise creates a new one
* Tries to load channel info from database if available
*/
export async function ensureChatContext(botCtx: BotContext, channelId: string): Promise<ChatContext> {
const log = botCtx.logger
if (botCtx.chats.has(channelId)) {
const existing = botCtx.chats.get(channelId)!
log
.withField('channelId', channelId)
.withField('platform', existing.platform)
.withField('selfId', existing.selfId)
.debug('ensureChatContext - returning existing chatContext')
return existing
}
// 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: [],
}
log
.withField('channelId', channelId)
.withField('platform', newChatContext.platform)
.withField('selfId', newChatContext.selfId)
.withField('foundInDb', !!channelInfo)
.debug('ensureChatContext - creating new chatContext')
botCtx.chats.set(channelId, newChatContext)
return newChatContext
}
@@ -1,18 +1,36 @@
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'
import type { SatoriEvent } from '../adapter/satori/types'
export interface PendingMessage {
message: SatoriMessage
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?.(),
}
}
export interface PendingEvent {
event: SatoriEvent
status: 'pending' | 'ready'
}
export interface BotContext {
logger: Logg
messageQueue: PendingMessage[]
unreadMessages: Record<string, SatoriMessage[]> // channelId -> messages
eventQueue: PendingEvent[]
unreadEvents: Record<string, SatoriEvent[]> // channelId -> events
processedIds: Set<string>
processing: boolean
lastInteractedChannelIds: string[]
+76
View File
@@ -0,0 +1,76 @@
import type { SatoriEvent, SatoriMessage } from '../adapter/satori/types'
import type { BotContext, ChatContext } from './types'
/**
* Safely extract string content from message
* Handles string, array, and other types
*/
export function getMessageContentString(content: unknown): string {
if (typeof content === 'string') {
return content
}
if (Array.isArray(content)) {
return content.map(c => typeof c === 'string' ? c : JSON.stringify(c)).join(' ')
}
return String(content || '')
}
/**
* Check if a message is from the bot itself
* Checks multiple possible sources for user ID
*/
export function isBotOwnMessage(
message: SatoriMessage,
event: SatoriEvent,
selfId?: string,
): boolean {
if (!selfId) {
return false
}
const sourceUserId = event.user?.id
|| event.member?.user?.id
|| message.user?.id
|| message.member?.user?.id
return sourceUserId === selfId
}
/**
* Format debug context for logging
* Creates a summary of bot state for debugging
*/
export function formatDebugContext(
ctx: BotContext,
chatCtx?: ChatContext,
): Record<string, unknown> {
const unreadEventsSummary = Object.fromEntries(
Object.entries(ctx.unreadEvents).map(([key, value]) => [key, value.length]),
)
const context: Record<string, unknown> = {
messageQueueLength: ctx.eventQueue.length,
unreadEvents: unreadEventsSummary,
totalUnreadCount: Object.values(ctx.unreadEvents).reduce((acc, cur) => acc + cur.length, 0),
}
if (chatCtx) {
context.channelId = chatCtx.channelId
context.totalMessagesInContext = chatCtx.messages.length
context.totalActionsInContext = chatCtx.actions.length
const lastMessages = chatCtx.messages.slice(-3).map(msg => ({
role: msg.role,
content: getMessageContentString(msg.content).substring(0, 50),
}))
context.lastMessages = lastMessages
const lastActions = chatCtx.actions.slice(-3).map(action => ({
action: action.action.action,
result: typeof action.result === 'string' ? action.result.substring(0, 100) : String(action.result).substring(0, 100),
}))
context.lastActions = lastActions
}
return context
}
+8 -55
View File
@@ -2,9 +2,10 @@ 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'
import { SatoriClient } from './adapter/satori/client'
import { globalRegistry } from './capabilities/registry'
import { createBotContext, setupMessageEventHandler, setupReadyEventHandler, startPeriodicLoop } from './core'
import { initDb } from './lib/db'
setGlobalFormat(Format.Pretty)
setGlobalLogLevel(LogLevel.Debug)
@@ -27,63 +28,15 @@ async function main() {
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)
})
setupReadyEventHandler(satoriClient, log)
setupMessageEventHandler(satoriClient, botContext, log)
// Connect to Satori server
await satoriClient.connect()
log.log('Connected to Satori server')
globalRegistry.loadStandardActions(satoriClient)
// Start periodic loop
startPeriodicLoop(botContext, satoriClient)
log.log('Periodic loop started')
@@ -1,9 +0,0 @@
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.
-18
View File
@@ -1,18 +0,0 @@
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?.(),
}
}