style: lint
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/lib/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
driver: 'pglite',
|
||||
dbCredentials: {
|
||||
url: './data/pglite-db',
|
||||
},
|
||||
dialect: 'postgresql',
|
||||
driver: 'pglite',
|
||||
out: './drizzle',
|
||||
schema: './src/lib/schema.ts',
|
||||
})
|
||||
|
||||
@@ -10,9 +10,9 @@ const log = useLogg('SatoriAPI')
|
||||
|
||||
export interface SatoriAPIConfig {
|
||||
baseUrl: string
|
||||
token?: string
|
||||
platform: string
|
||||
selfId: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
export class SatoriAPI {
|
||||
@@ -22,6 +22,43 @@ export class SatoriAPI {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
async deleteMessage(channelId: string, messageId: string): Promise<void> {
|
||||
await this.request('/message.delete', {
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
})
|
||||
}
|
||||
|
||||
async getMessage(channelId: string, messageId: string): Promise<SatoriMessage> {
|
||||
const response = await this.request<unknown>('/message.get', {
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
})
|
||||
return v.parse(SatoriMessageSchema, response)
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
channelId: string,
|
||||
content: string,
|
||||
): Promise<SatoriMessageCreateResponse[]> {
|
||||
const body: SatoriMessageCreateRequest = {
|
||||
channel_id: channelId,
|
||||
content,
|
||||
}
|
||||
|
||||
log.log(`Sending message to channel ${channelId}: ${content}`)
|
||||
const response = await this.request<unknown[]>('/message.create', body)
|
||||
return v.parse(v.array(SatoriMessageCreateResponseSchema), response)
|
||||
}
|
||||
|
||||
async updateMessage(channelId: string, messageId: string, content: string): Promise<void> {
|
||||
await this.request('/message.update', {
|
||||
channel_id: channelId,
|
||||
content,
|
||||
message_id: messageId,
|
||||
})
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -44,9 +81,9 @@ export class SatoriAPI {
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: this.getHeaders(),
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
@@ -62,41 +99,4 @@ export class SatoriAPI {
|
||||
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}`)
|
||||
const response = await this.request<unknown[]>('/message.create', body)
|
||||
return v.parse(v.array(SatoriMessageCreateResponseSchema), response)
|
||||
}
|
||||
|
||||
async getMessage(channelId: string, messageId: string): Promise<SatoriMessage> {
|
||||
const response = await this.request<unknown>('/message.get', {
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
})
|
||||
return v.parse(SatoriMessageSchema, response)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,24 +13,24 @@ import { SatoriOpcode } from './types'
|
||||
const log = useLogg('SatoriClient')
|
||||
|
||||
export interface SatoriClientConfig {
|
||||
url: string
|
||||
token?: string
|
||||
apiBaseUrl?: string
|
||||
token?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export class SatoriClient {
|
||||
private ws?: WebSocket
|
||||
private apiClients = new Map<string, SatoriAPI>()
|
||||
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>
|
||||
private eventHandlers = new Map<string, Set<(event: SatoriEvent) => Promise<void> | void>>()
|
||||
private heartbeatInterval?: NodeJS.Timeout
|
||||
private lastSequenceNumber = 0
|
||||
private readyHandler?: (logins: SatoriReadyBody) => Promise<void> | void
|
||||
private reconnectTimeout?: NodeJS.Timeout
|
||||
|
||||
private shouldReconnect = true
|
||||
private ws?: WebSocket
|
||||
|
||||
constructor(config: SatoriClientConfig) {
|
||||
this.config = config
|
||||
@@ -84,86 +84,95 @@ export class SatoriClient {
|
||||
})
|
||||
}
|
||||
|
||||
private sendIdentify(): void {
|
||||
const body: SatoriIdentifyBody = {
|
||||
token: this.config.token,
|
||||
sn: this.lastSequenceNumber,
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false
|
||||
this.stopHeartbeat()
|
||||
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout)
|
||||
this.reconnectTimeout = undefined
|
||||
}
|
||||
|
||||
this.sendSignal({
|
||||
op: SatoriOpcode.IDENTIFY,
|
||||
body,
|
||||
})
|
||||
if (this.ws) {
|
||||
this.ws.removeAllListeners()
|
||||
this.ws.close()
|
||||
this.ws = undefined
|
||||
}
|
||||
|
||||
log.log('Sent IDENTIFY signal')
|
||||
this.connected = false
|
||||
log.log('Disconnected from Satori server')
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
// Send PING every 10 seconds
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
if (this.connected) {
|
||||
this.sendSignal({ op: SatoriOpcode.PING })
|
||||
isConnected(): boolean {
|
||||
return this.connected
|
||||
}
|
||||
|
||||
off(eventType: string, handler?: (event: SatoriEvent) => Promise<void> | void): void {
|
||||
const handlers = this.eventHandlers.get(eventType)
|
||||
if (!handlers) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handler) {
|
||||
handlers.delete(handler)
|
||||
if (handlers.size === 0) {
|
||||
this.eventHandlers.delete(eventType)
|
||||
}
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval)
|
||||
this.heartbeatInterval = undefined
|
||||
}
|
||||
else {
|
||||
this.eventHandlers.delete(eventType)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage(data: WebSocket.Data): Promise<void> {
|
||||
// Event subscription
|
||||
on(eventType: string, handler: (event: SatoriEvent) => Promise<void> | void): void {
|
||||
let handlers = this.eventHandlers.get(eventType)
|
||||
if (!handlers) {
|
||||
handlers = new Set()
|
||||
this.eventHandlers.set(eventType, handlers)
|
||||
}
|
||||
handlers.add(handler)
|
||||
}
|
||||
|
||||
onReady(handler: (logins: SatoriReadyBody) => Promise<void> | void): void {
|
||||
this.readyHandler = handler
|
||||
}
|
||||
|
||||
// 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) {
|
||||
log.error(`No API client found for ${key}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rawData = JSON.parse(data.toString())
|
||||
const signal = v.parse(SatoriSignalSchema, rawData)
|
||||
|
||||
switch (signal.op) {
|
||||
case SatoriOpcode.READY: {
|
||||
const readyBody = v.parse(SatoriReadyBodySchema, signal.body)
|
||||
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 = v.parse(SatoriEventSchema, signal.body)
|
||||
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)
|
||||
}
|
||||
await api.sendMessage(channelId, content)
|
||||
log.log(`Message sent to channel ${channelId}`)
|
||||
}
|
||||
catch (error) {
|
||||
if (v.isValiError(error)) {
|
||||
log.error('Satori protocol validation failed:')
|
||||
for (const issue of error.issues) {
|
||||
log.error(` - ${issue.path?.map(p => p.key).join('.')}: ${issue.message}`)
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.withError(error as Error).error('Failed to handle message')
|
||||
}
|
||||
log.withError(error as Error).error('Failed to send message')
|
||||
}
|
||||
}
|
||||
|
||||
private handleDisconnect(): void {
|
||||
this.connected = false
|
||||
this.stopHeartbeat()
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.removeAllListeners()
|
||||
this.ws = undefined
|
||||
}
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
log.log('Attempting to reconnect in 5 seconds...')
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
void this.connect()
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,29 +199,56 @@ export class SatoriClient {
|
||||
}
|
||||
}
|
||||
|
||||
private handleDisconnect(): void {
|
||||
this.connected = false
|
||||
this.stopHeartbeat()
|
||||
private async handleMessage(data: WebSocket.Data): Promise<void> {
|
||||
try {
|
||||
const rawData = JSON.parse(data.toString())
|
||||
const signal = v.parse(SatoriSignalSchema, rawData)
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.removeAllListeners()
|
||||
this.ws = undefined
|
||||
}
|
||||
switch (signal.op) {
|
||||
case SatoriOpcode.EVENT: {
|
||||
const event = v.parse(SatoriEventSchema, signal.body)
|
||||
this.lastSequenceNumber = event.id
|
||||
await this.handleEvent(event)
|
||||
break
|
||||
}
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
log.log('Attempting to reconnect in 5 seconds...')
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
void this.connect()
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
case SatoriOpcode.META: {
|
||||
log.log('Received META signal:', signal.body)
|
||||
break
|
||||
}
|
||||
|
||||
private sendSignal(signal: SatoriSignal): void {
|
||||
if (this.ws && this.connected) {
|
||||
this.ws.send(JSON.stringify(signal))
|
||||
case SatoriOpcode.PONG: {
|
||||
// Heartbeat response received
|
||||
break
|
||||
}
|
||||
|
||||
case SatoriOpcode.READY: {
|
||||
const readyBody = v.parse(SatoriReadyBodySchema, signal.body)
|
||||
log.log('Received READY signal')
|
||||
|
||||
// Initialize API clients for each login
|
||||
this.initializeAPIClients(readyBody)
|
||||
|
||||
if (this.readyHandler) {
|
||||
await this.readyHandler(readyBody)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
log.warn('Unknown opcode received:', signal.op)
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.warn('Cannot send signal: not connected')
|
||||
catch (error) {
|
||||
if (v.isValiError(error)) {
|
||||
log.error('Satori protocol validation failed:')
|
||||
for (const issue of error.issues) {
|
||||
log.error(` - ${issue.path?.map(p => p.key).join('.')}: ${issue.message}`)
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.withError(error as Error).error('Failed to handle message')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,87 +260,51 @@ export class SatoriClient {
|
||||
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,
|
||||
token: this.config.token,
|
||||
}))
|
||||
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}`
|
||||
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) {
|
||||
log.error(`No API client found for ${key}`)
|
||||
return
|
||||
private sendIdentify(): void {
|
||||
const body: SatoriIdentifyBody = {
|
||||
sn: this.lastSequenceNumber,
|
||||
token: this.config.token,
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
this.sendSignal({
|
||||
body,
|
||||
op: SatoriOpcode.IDENTIFY,
|
||||
})
|
||||
|
||||
log.log('Sent IDENTIFY signal')
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
private sendSignal(signal: SatoriSignal): void {
|
||||
if (this.ws && this.connected) {
|
||||
this.ws.send(JSON.stringify(signal))
|
||||
}
|
||||
else {
|
||||
this.eventHandlers.delete(eventType)
|
||||
log.warn('Cannot send signal: not connected')
|
||||
}
|
||||
}
|
||||
|
||||
onReady(handler: (logins: SatoriReadyBody) => void | Promise<void>): void {
|
||||
this.readyHandler = handler
|
||||
private startHeartbeat(): void {
|
||||
// Send PING every 10 seconds
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
if (this.connected) {
|
||||
this.sendSignal({ op: SatoriOpcode.PING })
|
||||
}
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false
|
||||
this.stopHeartbeat()
|
||||
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout)
|
||||
this.reconnectTimeout = undefined
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval)
|
||||
this.heartbeatInterval = undefined
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.removeAllListeners()
|
||||
this.ws.close()
|
||||
this.ws = undefined
|
||||
}
|
||||
|
||||
this.connected = false
|
||||
log.log('Disconnected from Satori server')
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,89 @@
|
||||
import * as v from 'valibot'
|
||||
|
||||
export const SatoriUserSchema = v.object({
|
||||
avatar: v.optional(v.string()),
|
||||
id: v.string(),
|
||||
is_bot: v.optional(v.boolean()),
|
||||
name: v.optional(v.string()),
|
||||
nick: v.optional(v.string()),
|
||||
avatar: v.optional(v.string()),
|
||||
is_bot: v.optional(v.boolean()),
|
||||
})
|
||||
|
||||
export const SatoriChannelSchema = v.object({
|
||||
id: v.string(),
|
||||
type: v.number(),
|
||||
name: v.optional(v.string()),
|
||||
parent_id: v.optional(v.string()),
|
||||
type: v.number(),
|
||||
})
|
||||
|
||||
export const SatoriGuildSchema = v.object({
|
||||
avatar: v.optional(v.string()),
|
||||
id: v.string(),
|
||||
name: v.optional(v.string()),
|
||||
avatar: v.optional(v.string()),
|
||||
})
|
||||
|
||||
export const SatoriGuildMemberSchema = v.object({
|
||||
user: v.optional(SatoriUserSchema),
|
||||
nick: v.optional(v.string()),
|
||||
avatar: v.optional(v.string()),
|
||||
joined_at: v.optional(v.number()),
|
||||
nick: v.optional(v.string()),
|
||||
user: v.optional(SatoriUserSchema),
|
||||
})
|
||||
|
||||
export const SatoriMessageSchema = v.object({
|
||||
id: v.string(),
|
||||
content: v.string(),
|
||||
platform: v.optional(v.string()),
|
||||
channel: v.optional(SatoriChannelSchema),
|
||||
guild: v.optional(SatoriGuildSchema),
|
||||
member: v.optional(SatoriGuildMemberSchema),
|
||||
user: v.optional(SatoriUserSchema),
|
||||
content: v.string(),
|
||||
created_at: v.optional(v.number()),
|
||||
guild: v.optional(SatoriGuildSchema),
|
||||
id: v.string(),
|
||||
member: v.optional(SatoriGuildMemberSchema),
|
||||
platform: v.optional(v.string()),
|
||||
updated_at: v.optional(v.number()),
|
||||
user: v.optional(SatoriUserSchema),
|
||||
})
|
||||
|
||||
export const SatoriLoginSchema = v.object({
|
||||
user: v.optional(SatoriUserSchema),
|
||||
self_id: v.optional(v.string()),
|
||||
platform: v.optional(v.string()),
|
||||
status: v.number(),
|
||||
features: v.optional(v.array(v.string())),
|
||||
platform: v.optional(v.string()),
|
||||
proxy_urls: v.optional(v.array(v.string())),
|
||||
self_id: v.optional(v.string()),
|
||||
status: v.number(),
|
||||
user: v.optional(SatoriUserSchema),
|
||||
})
|
||||
|
||||
export const SatoriArgvSchema = v.object({
|
||||
name: v.string(),
|
||||
arguments: v.array(v.unknown()),
|
||||
name: v.string(),
|
||||
options: v.record(v.string(), v.unknown()),
|
||||
})
|
||||
|
||||
export const SatoriEventSchema = v.object({
|
||||
id: v.number(),
|
||||
type: v.string(),
|
||||
platform: v.string(),
|
||||
self_id: v.string(),
|
||||
timestamp: v.number(),
|
||||
_data: v.optional(v.record(v.string(), v.unknown())),
|
||||
_type: v.optional(v.string()),
|
||||
argv: v.optional(SatoriArgvSchema),
|
||||
button: v.optional(v.object({ id: v.string() })),
|
||||
channel: v.optional(SatoriChannelSchema),
|
||||
guild: v.optional(SatoriGuildSchema),
|
||||
id: v.number(),
|
||||
login: v.optional(SatoriLoginSchema),
|
||||
member: v.optional(SatoriGuildMemberSchema),
|
||||
message: v.optional(SatoriMessageSchema),
|
||||
operator: v.optional(SatoriUserSchema),
|
||||
platform: v.string(),
|
||||
role: v.optional(v.object({ id: v.string(), name: v.optional(v.string()) })),
|
||||
self_id: v.string(),
|
||||
timestamp: v.number(),
|
||||
type: v.string(),
|
||||
user: v.optional(SatoriUserSchema),
|
||||
_type: v.optional(v.string()),
|
||||
_data: v.optional(v.record(v.string(), v.unknown())),
|
||||
})
|
||||
|
||||
export const SatoriMessageCreateResponseSchema = v.object({
|
||||
id: v.string(),
|
||||
content: v.optional(v.string()),
|
||||
channel: v.optional(SatoriChannelSchema),
|
||||
guild: v.optional(SatoriGuildSchema),
|
||||
member: v.optional(SatoriGuildMemberSchema),
|
||||
user: v.optional(SatoriUserSchema),
|
||||
content: v.optional(v.string()),
|
||||
created_at: v.optional(v.number()),
|
||||
guild: v.optional(SatoriGuildSchema),
|
||||
id: v.string(),
|
||||
member: v.optional(SatoriGuildMemberSchema),
|
||||
updated_at: v.optional(v.number()),
|
||||
user: v.optional(SatoriUserSchema),
|
||||
})
|
||||
|
||||
export const SatoriReadyBodySchema = v.object({
|
||||
@@ -92,8 +92,8 @@ export const SatoriReadyBodySchema = v.object({
|
||||
})
|
||||
|
||||
export const SatoriSignalSchema = v.object({
|
||||
op: v.number(),
|
||||
body: v.optional(v.unknown()),
|
||||
op: v.number(),
|
||||
})
|
||||
|
||||
export function SatoriListSchema<T extends v.BaseSchema<any, any, any>>(itemSchema: T) {
|
||||
|
||||
@@ -13,59 +13,67 @@ export enum SatoriOpcode {
|
||||
META = 5, // 接收元信息更新
|
||||
}
|
||||
|
||||
// WebSocket Signal Structure
|
||||
export interface SatoriSignal<T = unknown> {
|
||||
op: SatoriOpcode
|
||||
body?: T
|
||||
// Interaction Argv
|
||||
export interface SatoriArgv {
|
||||
arguments: unknown[]
|
||||
name: string
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
// IDENTIFY signal body
|
||||
export interface SatoriIdentifyBody {
|
||||
token?: string
|
||||
sn?: number
|
||||
// Bidirectional paginated list
|
||||
export interface SatoriBidiList<T> {
|
||||
data: T[]
|
||||
next?: string
|
||||
prev?: string
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Interaction Button
|
||||
export interface SatoriButton {
|
||||
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
|
||||
type: number
|
||||
}
|
||||
|
||||
// Event structure
|
||||
export interface SatoriEvent {
|
||||
_data?: Record<string, unknown>
|
||||
_type?: string
|
||||
argv?: SatoriArgv
|
||||
button?: SatoriButton
|
||||
channel?: SatoriChannel
|
||||
guild?: SatoriGuild
|
||||
id: number
|
||||
login?: SatoriLogin
|
||||
member?: SatoriGuildMember
|
||||
message?: SatoriMessage
|
||||
operator?: SatoriUser
|
||||
platform: string
|
||||
role?: SatoriGuildRole
|
||||
self_id: string
|
||||
timestamp: number
|
||||
type: string
|
||||
user?: SatoriUser
|
||||
}
|
||||
|
||||
// Guild resource
|
||||
export interface SatoriGuild {
|
||||
avatar?: string
|
||||
id: string
|
||||
name?: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
// Guild Member resource
|
||||
export interface SatoriGuildMember {
|
||||
user?: SatoriUser
|
||||
nick?: string
|
||||
avatar?: string
|
||||
joined_at?: number
|
||||
nick?: string
|
||||
user?: SatoriUser
|
||||
}
|
||||
|
||||
// Guild Role resource
|
||||
@@ -74,60 +82,39 @@ export interface SatoriGuildRole {
|
||||
name?: string
|
||||
}
|
||||
|
||||
// Message resource
|
||||
export interface SatoriMessage {
|
||||
id: string
|
||||
content: string
|
||||
platform?: string
|
||||
channel?: SatoriChannel
|
||||
guild?: SatoriGuild
|
||||
member?: SatoriGuildMember
|
||||
user?: SatoriUser
|
||||
created_at?: number
|
||||
updated_at?: number
|
||||
// IDENTIFY signal body
|
||||
export interface SatoriIdentifyBody {
|
||||
sn?: number
|
||||
token?: string
|
||||
}
|
||||
|
||||
// Paginated list
|
||||
export interface SatoriList<T> {
|
||||
data: T[]
|
||||
next?: string
|
||||
}
|
||||
|
||||
// Login resource
|
||||
export interface SatoriLogin {
|
||||
user?: SatoriUser
|
||||
self_id?: string
|
||||
platform?: string
|
||||
status: number
|
||||
features?: string[]
|
||||
platform?: string
|
||||
proxy_urls?: string[]
|
||||
self_id?: string
|
||||
status: number
|
||||
user?: SatoriUser
|
||||
}
|
||||
|
||||
// Interaction Argv
|
||||
export interface SatoriArgv {
|
||||
name: string
|
||||
arguments: unknown[]
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
// 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
|
||||
// Message resource
|
||||
export interface SatoriMessage {
|
||||
channel?: SatoriChannel
|
||||
content: string
|
||||
created_at?: number
|
||||
guild?: SatoriGuild
|
||||
login?: SatoriLogin
|
||||
id: string
|
||||
member?: SatoriGuildMember
|
||||
message?: SatoriMessage
|
||||
operator?: SatoriUser
|
||||
role?: SatoriGuildRole
|
||||
platform?: string
|
||||
updated_at?: number
|
||||
user?: SatoriUser
|
||||
_type?: string
|
||||
_data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// API Request/Response types
|
||||
@@ -137,25 +124,38 @@ export interface SatoriMessageCreateRequest {
|
||||
}
|
||||
|
||||
export interface SatoriMessageCreateResponse {
|
||||
id: string
|
||||
content?: string
|
||||
channel?: SatoriChannel
|
||||
guild?: SatoriGuild
|
||||
member?: SatoriGuildMember
|
||||
user?: SatoriUser
|
||||
content?: string
|
||||
created_at?: number
|
||||
guild?: SatoriGuild
|
||||
id: string
|
||||
member?: SatoriGuildMember
|
||||
updated_at?: number
|
||||
user?: SatoriUser
|
||||
}
|
||||
|
||||
// Paginated list
|
||||
export interface SatoriList<T> {
|
||||
data: T[]
|
||||
next?: string
|
||||
// META signal body
|
||||
export interface SatoriMetaBody {
|
||||
proxy_urls?: string[]
|
||||
}
|
||||
|
||||
// Bidirectional paginated list
|
||||
export interface SatoriBidiList<T> {
|
||||
data: T[]
|
||||
prev?: string
|
||||
next?: string
|
||||
// READY signal body
|
||||
export interface SatoriReadyBody {
|
||||
logins: SatoriLogin[]
|
||||
proxy_urls?: string[]
|
||||
}
|
||||
|
||||
// WebSocket Signal Structure
|
||||
export interface SatoriSignal<T = unknown> {
|
||||
body?: T
|
||||
op: SatoriOpcode
|
||||
}
|
||||
|
||||
// User resource
|
||||
export interface SatoriUser {
|
||||
avatar?: string
|
||||
id: string
|
||||
is_bot?: boolean
|
||||
name?: string
|
||||
nick?: string
|
||||
}
|
||||
|
||||
@@ -5,14 +5,13 @@ import { useLogg } from '@guiiai/logg'
|
||||
import { deleteUnreadEventsByIds } from '../../lib/db'
|
||||
|
||||
export const readMessagesAction: ActionHandler = {
|
||||
name: 'read_unread_messages',
|
||||
description: 'Read unread messages from a specific channel',
|
||||
execute: async (botContext, chatCtx, args): Promise<ActionResult> => {
|
||||
if (args.action !== 'read_unread_messages') {
|
||||
return {
|
||||
success: false,
|
||||
shouldContinue: true,
|
||||
result: 'System Error: Action mismatch for read_unread_messages.',
|
||||
shouldContinue: true,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
const logger = useLogg('readMessagesAction').useGlobalConfig()
|
||||
@@ -20,9 +19,9 @@ export const readMessagesAction: ActionHandler = {
|
||||
|
||||
if (!channelId) {
|
||||
return {
|
||||
success: false,
|
||||
shouldContinue: true,
|
||||
result: 'System Error: No channelId provided for read_unread_messages.',
|
||||
shouldContinue: true,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +30,9 @@ export const readMessagesAction: ActionHandler = {
|
||||
if (!unreadEventsForThisChannel || unreadEventsForThisChannel.length === 0) {
|
||||
delete botContext.unreadEvents[channelId]
|
||||
return {
|
||||
success: true,
|
||||
shouldContinue: true,
|
||||
result: 'AIRI System: No unread messages found.',
|
||||
shouldContinue: true,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +59,10 @@ export const readMessagesAction: ActionHandler = {
|
||||
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}`,
|
||||
shouldContinue: true,
|
||||
success: true,
|
||||
}
|
||||
},
|
||||
name: 'read_unread_messages',
|
||||
}
|
||||
|
||||
@@ -7,13 +7,12 @@ import { recordMessage } from '../../lib/db'
|
||||
|
||||
export function createSendMessageAction(client: SatoriClient): ActionHandler {
|
||||
return {
|
||||
name: 'send_message',
|
||||
execute: async (ctx, chatCtx, args) => {
|
||||
if (args.action !== 'send_message') {
|
||||
return {
|
||||
success: false,
|
||||
shouldContinue: true,
|
||||
result: 'System Error: Action mismatch for send_message.',
|
||||
shouldContinue: true,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
const logger = useLogg('Action:send_message').useGlobalConfig()
|
||||
@@ -24,9 +23,9 @@ export function createSendMessageAction(client: SatoriClient): ActionHandler {
|
||||
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.',
|
||||
shouldContinue: true,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,19 +37,20 @@ export function createSendMessageAction(client: SatoriClient): ActionHandler {
|
||||
await recordMessage(channelId, chatCtx.selfId, 'AIRI', content)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
shouldContinue: true,
|
||||
result: `AIRI System: Message sent to ${channelId}: ${content}`,
|
||||
shouldContinue: true,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
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}`,
|
||||
shouldContinue: true,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
name: 'send_message',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,60 +5,60 @@ 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.',
|
||||
shouldContinue: false,
|
||||
success: true,
|
||||
}
|
||||
},
|
||||
name: 'continue',
|
||||
}
|
||||
|
||||
// 2. Break Action
|
||||
export const breakAction: ActionHandler = {
|
||||
name: 'break',
|
||||
execute: async (_ctx, chatCtx): Promise<ActionResult> => {
|
||||
chatCtx.actions = []
|
||||
return {
|
||||
success: true,
|
||||
shouldContinue: false,
|
||||
result: 'AIRI System: Memory cleared. Loop broken.',
|
||||
shouldContinue: false,
|
||||
success: true,
|
||||
}
|
||||
},
|
||||
name: 'break',
|
||||
}
|
||||
|
||||
// 3. Sleep Action
|
||||
export const sleepAction: ActionHandler = {
|
||||
name: 'sleep',
|
||||
execute: async (_ctx, _chatCtx, args): Promise<ActionResult> => {
|
||||
if (args.action !== 'sleep') {
|
||||
return {
|
||||
success: false,
|
||||
shouldContinue: true,
|
||||
result: 'System Error: Action mismatch for sleep.',
|
||||
shouldContinue: true,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
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.`,
|
||||
shouldContinue: true,
|
||||
success: true,
|
||||
}
|
||||
},
|
||||
name: 'sleep',
|
||||
}
|
||||
|
||||
// 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}`,
|
||||
shouldContinue: true,
|
||||
success: true,
|
||||
}
|
||||
},
|
||||
name: 'list_channels',
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import type { Action, BotContext, ChatContext } from '../core/types'
|
||||
|
||||
export interface ActionResult {
|
||||
success: boolean
|
||||
shouldContinue: boolean
|
||||
result: unknown
|
||||
}
|
||||
|
||||
export interface ActionHandler {
|
||||
name: string
|
||||
description?: string
|
||||
execute: (
|
||||
ctx: BotContext,
|
||||
@@ -15,4 +8,11 @@ export interface ActionHandler {
|
||||
args: Action,
|
||||
abortSignal?: AbortSignal,
|
||||
) => Promise<ActionResult>
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
result: unknown
|
||||
shouldContinue: boolean
|
||||
success: boolean
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@ import { breakAction, continueAction, listChannelsAction, sleepAction } from './
|
||||
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)
|
||||
}
|
||||
@@ -29,6 +25,10 @@ export class ActionRegistry {
|
||||
this.register(createSendMessageAction(client))
|
||||
this.register(readMessagesAction)
|
||||
}
|
||||
|
||||
register(handler: ActionHandler) {
|
||||
this.actions.set(handler.name, handler)
|
||||
}
|
||||
}
|
||||
|
||||
export const globalRegistry = new ActionRegistry()
|
||||
|
||||
@@ -3,10 +3,8 @@ import { env, exit } from 'node:process'
|
||||
import * as v from 'valibot'
|
||||
|
||||
const ConfigSchema = v.object({
|
||||
satori: v.object({
|
||||
wsUrl: v.string(),
|
||||
token: v.optional(v.string()),
|
||||
apiBaseUrl: v.optional(v.string()),
|
||||
db: v.object({
|
||||
path: v.optional(v.string(), '../../data/pglite-db'),
|
||||
}),
|
||||
llm: v.object({
|
||||
apiKey: v.string(),
|
||||
@@ -14,25 +12,19 @@ const ConfigSchema = v.object({
|
||||
model: v.string(),
|
||||
ollamaDisableThink: v.optional(v.boolean(), false),
|
||||
}),
|
||||
db: v.object({
|
||||
path: v.optional(v.string(), '../../data/pglite-db'),
|
||||
satori: v.object({
|
||||
apiBaseUrl: v.optional(v.string()),
|
||||
token: v.optional(v.string()),
|
||||
wsUrl: v.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
export type Config = v.InferOutput<typeof ConfigSchema>
|
||||
|
||||
function parseBoolean(value: string | undefined): boolean | undefined {
|
||||
if (value === undefined)
|
||||
return undefined
|
||||
return value.toLowerCase() === 'true' || value === '1'
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
const rawConfig = {
|
||||
satori: {
|
||||
wsUrl: env.SATORI_WS_URL || 'ws://localhost:5140/satori/v1/events',
|
||||
token: env.SATORI_TOKEN,
|
||||
apiBaseUrl: env.SATORI_API_BASE_URL,
|
||||
db: {
|
||||
path: env.DB_PATH,
|
||||
},
|
||||
llm: {
|
||||
apiKey: env.LLM_API_KEY,
|
||||
@@ -40,8 +32,10 @@ export function loadConfig(): Config {
|
||||
model: env.LLM_MODEL,
|
||||
ollamaDisableThink: parseBoolean(env.LLM_OLLAMA_DISABLE_THINK),
|
||||
},
|
||||
db: {
|
||||
path: env.DB_PATH,
|
||||
satori: {
|
||||
apiBaseUrl: env.SATORI_API_BASE_URL,
|
||||
token: env.SATORI_TOKEN,
|
||||
wsUrl: env.SATORI_WS_URL || 'ws://localhost:5140/satori/v1/events',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -62,4 +56,10 @@ export function loadConfig(): Config {
|
||||
}
|
||||
}
|
||||
|
||||
function parseBoolean(value: string | undefined): boolean | undefined {
|
||||
if (value === undefined)
|
||||
return undefined
|
||||
return value.toLowerCase() === 'true' || value === '1'
|
||||
}
|
||||
|
||||
export const config = loadConfig()
|
||||
|
||||
@@ -18,9 +18,9 @@ export async function dispatchAction(
|
||||
|
||||
if (!parseResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
shouldContinue: true,
|
||||
result: `System Error: Invalid action payload: ${parseResult.issues.map(i => i.message).join(', ')}`,
|
||||
shouldContinue: true,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@ export async function dispatchAction(
|
||||
|
||||
if (!handler) {
|
||||
return {
|
||||
success: false,
|
||||
shouldContinue: true,
|
||||
result: `System Error: Action "${validatedAction.action}" is not implemented.`,
|
||||
shouldContinue: true,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +50,9 @@ export async function dispatchAction(
|
||||
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}`,
|
||||
shouldContinue: true,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,24 +7,6 @@ import type { BotContext } from '../types'
|
||||
import { pushToEventQueue } from '../../lib/db'
|
||||
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
|
||||
@@ -68,3 +50,21 @@ export function setupMessageEventHandler(
|
||||
await onMessageArrival(botContext, satoriClient)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ export async function handleLoopStep(
|
||||
// Dynamic history injection: Fetch last 10 messages from DB
|
||||
const dbMessages = await getRecentMessages(chatCtx.channelId, 10)
|
||||
const llmMessages: LLMMessage[] = dbMessages.map(m => ({
|
||||
role: m.userId === chatCtx.selfId ? 'assistant' : 'user',
|
||||
content: m.content,
|
||||
role: m.userId === chatCtx.selfId ? 'assistant' : 'user',
|
||||
}))
|
||||
|
||||
const actionPayload = await imagineAnAction(
|
||||
@@ -79,8 +79,8 @@ export async function handleLoopStep(
|
||||
llmMessages,
|
||||
chatCtx?.actions || [],
|
||||
{
|
||||
unreadEvents: ctx.unreadEvents,
|
||||
incomingEvents: currentIncoming ? [currentIncoming] : [],
|
||||
unreadEvents: ctx.unreadEvents,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -129,6 +129,14 @@ export async function loopIterationForChannel(
|
||||
await handleLoopStep(bot, satoriClient, chatContext, incomingEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic loop
|
||||
* Begins the recursive periodic processing of channels with unread messages
|
||||
*/
|
||||
export function startPeriodicLoop(botCtx: BotContext, satoriClient: SatoriClient) {
|
||||
loopPeriodic(botCtx, satoriClient)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process periodic loop iteration for existing channels with unread messages
|
||||
* Only processes channels that have unread messages to avoid unnecessary LLM calls
|
||||
@@ -200,14 +208,6 @@ function loopPeriodic(botCtx: BotContext, satoriClient: 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)
|
||||
}
|
||||
|
||||
let isQueueConsumerRunning = false
|
||||
|
||||
/**
|
||||
@@ -267,9 +267,9 @@ export async function onMessageArrival(
|
||||
botContext.logger
|
||||
.withFields({
|
||||
channelId: chatCtx.channelId,
|
||||
sourceUserId: currMsg.event.user?.id || currMsg.event.member?.user?.id,
|
||||
selfId: chatCtx.selfId,
|
||||
messageId: currMsg.event.id,
|
||||
selfId: chatCtx.selfId,
|
||||
sourceUserId: currMsg.event.user?.id || currMsg.event.member?.user?.id,
|
||||
})
|
||||
.debug('[DEBUG] Skipping bot\'s own event in unreadEvents - filtered out')
|
||||
botContext.eventQueue.shift()
|
||||
@@ -294,7 +294,7 @@ export async function onMessageArrival(
|
||||
}
|
||||
|
||||
const unreadEventId = await pushToUnreadEvents(chatCtx.channelId, currMsg.event)
|
||||
unreadEventsForThisChannel.push({ id: unreadEventId, event: currMsg.event })
|
||||
unreadEventsForThisChannel.push({ event: currMsg.event, id: unreadEventId })
|
||||
|
||||
if (unreadEventsForThisChannel.length > MAX_UNREAD_EVENTS) {
|
||||
unreadEventsForThisChannel = unreadEventsForThisChannel.slice(-MAX_UNREAD_EVENTS)
|
||||
|
||||
@@ -20,8 +20,8 @@ export async function imagineAnAction(
|
||||
messages: LLMMessage[],
|
||||
actions: { action: Action, result: unknown }[],
|
||||
globalStates: {
|
||||
unreadEvents: Record<string, StoredUnreadEvent[]>
|
||||
incomingEvents?: SatoriEvent[]
|
||||
unreadEvents: Record<string, StoredUnreadEvent[]>
|
||||
},
|
||||
): Promise<Action | undefined> {
|
||||
const logger = useLogg('imagineAnAction').useGlobalConfig()
|
||||
@@ -57,11 +57,11 @@ export async function imagineAnAction(
|
||||
|
||||
try {
|
||||
const req = {
|
||||
abortSignal: currentAbortController?.signal,
|
||||
apiKey: config.llm.apiKey,
|
||||
baseURL: config.llm.baseUrl,
|
||||
model: config.llm.model,
|
||||
messages: requestMessages,
|
||||
abortSignal: currentAbortController?.signal,
|
||||
model: config.llm.model,
|
||||
} satisfies GenerateTextOptions
|
||||
|
||||
if (config.llm.ollamaDisableThink) {
|
||||
@@ -76,12 +76,12 @@ export async function imagineAnAction(
|
||||
}
|
||||
|
||||
logger.withFields({
|
||||
response: res.text,
|
||||
unreadEvents: Object.fromEntries(Object.entries(globalStates.unreadEvents).map(([key, value]) => [key, value.length])),
|
||||
now: new Date().toLocaleString(),
|
||||
totalTokens: res.usage.totalTokens,
|
||||
promptTokens: res.usage.inputTokens,
|
||||
completion_tokens: res.usage.outputTokens,
|
||||
now: new Date().toLocaleString(),
|
||||
promptTokens: res.usage.inputTokens,
|
||||
response: res.text,
|
||||
totalTokens: res.usage.totalTokens,
|
||||
unreadEvents: Object.fromEntries(Object.entries(globalStates.unreadEvents).map(([key, value]) => [key, value.length])),
|
||||
}).log('Generated action')
|
||||
|
||||
responseText = res.text
|
||||
|
||||
@@ -15,12 +15,12 @@ export async function createBotContext(logger: Logg): Promise<BotContext> {
|
||||
])
|
||||
|
||||
const botSelf: BotContext = {
|
||||
eventQueue,
|
||||
unreadEvents,
|
||||
processedIds: new Set(),
|
||||
logger,
|
||||
lastInteractedChannelIds: [],
|
||||
chats: new Map<string, ChatContext>(),
|
||||
eventQueue,
|
||||
lastInteractedChannelIds: [],
|
||||
logger,
|
||||
processedIds: new Set(),
|
||||
unreadEvents,
|
||||
}
|
||||
|
||||
return botSelf
|
||||
@@ -48,13 +48,13 @@ export async function ensureChatContext(botCtx: BotContext, channelId: string):
|
||||
const channelInfo = channels.find(c => c.id === channelId)
|
||||
|
||||
const newChatContext: ChatContext = {
|
||||
actions: [],
|
||||
channelId,
|
||||
currentAbortController: undefined,
|
||||
currentTask: undefined,
|
||||
isProcessing: false,
|
||||
platform: channelInfo?.platform || '',
|
||||
selfId: channelInfo?.selfId || '',
|
||||
isProcessing: false,
|
||||
currentTask: undefined,
|
||||
currentAbortController: undefined,
|
||||
actions: [],
|
||||
}
|
||||
|
||||
log
|
||||
|
||||
@@ -24,8 +24,8 @@ export const ListChannelsActionSchema = v.object({
|
||||
|
||||
export const SendMessageActionSchema = v.object({
|
||||
action: v.literal('send_message'),
|
||||
content: v.string(),
|
||||
channelId: v.string(),
|
||||
content: v.string(),
|
||||
})
|
||||
|
||||
export const ReadUnreadMessagesActionSchema = v.object({
|
||||
@@ -44,9 +44,42 @@ export const ActionSchema = v.union([
|
||||
|
||||
export type Action = v.InferOutput<typeof ActionSchema>
|
||||
|
||||
export interface BotContext {
|
||||
chats: Map<string, ChatContext>
|
||||
currentProcessingStartTime?: number
|
||||
eventQueue: PendingEvent[]
|
||||
lastInteractedChannelIds: string[]
|
||||
logger: Logg
|
||||
processedIds: Set<string>
|
||||
unreadEvents: Record<string, StoredUnreadEvent[]> // channelId -> events
|
||||
}
|
||||
|
||||
export interface CancellablePromise<T> {
|
||||
promise: Promise<T>
|
||||
cancel: () => void
|
||||
promise: Promise<T>
|
||||
}
|
||||
|
||||
export interface ChatContext {
|
||||
actions: { action: Action, result: unknown }[]
|
||||
channelId: string
|
||||
currentAbortController?: AbortController
|
||||
currentTask?: CancellablePromise<void>
|
||||
|
||||
isProcessing: boolean
|
||||
platform: string
|
||||
|
||||
selfId: string
|
||||
}
|
||||
|
||||
export interface PendingEvent {
|
||||
event: SatoriEvent
|
||||
id: string
|
||||
status: 'pending' | 'ready'
|
||||
}
|
||||
|
||||
export interface StoredUnreadEvent {
|
||||
event: SatoriEvent
|
||||
id: string
|
||||
}
|
||||
|
||||
export function cancellable<T>(promise: Promise<T>): CancellablePromise<T> {
|
||||
@@ -58,40 +91,7 @@ export function cancellable<T>(promise: Promise<T>): CancellablePromise<T> {
|
||||
})
|
||||
|
||||
return {
|
||||
promise: wrappedPromise,
|
||||
cancel: () => cancel?.(),
|
||||
promise: wrappedPromise,
|
||||
}
|
||||
}
|
||||
|
||||
export interface PendingEvent {
|
||||
id: string
|
||||
event: SatoriEvent
|
||||
status: 'pending' | 'ready'
|
||||
}
|
||||
|
||||
export interface StoredUnreadEvent {
|
||||
id: string
|
||||
event: SatoriEvent
|
||||
}
|
||||
|
||||
export interface BotContext {
|
||||
logger: Logg
|
||||
eventQueue: PendingEvent[]
|
||||
unreadEvents: Record<string, StoredUnreadEvent[]> // channelId -> events
|
||||
processedIds: Set<string>
|
||||
lastInteractedChannelIds: string[]
|
||||
currentProcessingStartTime?: number
|
||||
chats: Map<string, ChatContext>
|
||||
}
|
||||
|
||||
export interface ChatContext {
|
||||
channelId: string
|
||||
platform: string
|
||||
selfId: string
|
||||
isProcessing: boolean
|
||||
|
||||
currentTask?: CancellablePromise<void>
|
||||
currentAbortController?: AbortController
|
||||
|
||||
actions: { action: Action, result: unknown }[]
|
||||
}
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
import type { SatoriEvent, SatoriMessage } from '../adapter/satori/types'
|
||||
import type { Action, BotContext, ChatContext } from './types'
|
||||
/**
|
||||
* Intelligently truncate action history while preserving logical chains.
|
||||
* If the first action in the kept list is a 'continue', it backtracks to include
|
||||
* the action that triggered it, ensuring the LLM has full context of its sequence.
|
||||
* Format debug context for logging
|
||||
* Creates a summary of bot state for debugging
|
||||
*/
|
||||
export function trimActions(
|
||||
actions: { action: Action, result: unknown }[],
|
||||
max: number,
|
||||
keep: number,
|
||||
): { action: Action, result: unknown }[] {
|
||||
if (actions.length <= max) {
|
||||
return actions
|
||||
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,
|
||||
totalUnreadCount: Object.values(ctx.unreadEvents).reduce((acc, cur) => acc + cur.length, 0),
|
||||
unreadEvents: unreadEventsSummary,
|
||||
}
|
||||
|
||||
let startIndex = actions.length - keep
|
||||
if (chatCtx) {
|
||||
context.channelId = chatCtx.channelId
|
||||
context.totalActionsInContext = chatCtx.actions.length
|
||||
|
||||
// Backtrack to avoid starting with a 'continue' action which lacks its previous context
|
||||
while (startIndex > 0) {
|
||||
const currentAction = actions[startIndex].action
|
||||
if (currentAction.action === 'continue') {
|
||||
startIndex--
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
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 actions.slice(startIndex)
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,33 +69,31 @@ export function isBotOwnMessage(
|
||||
}
|
||||
|
||||
/**
|
||||
* Format debug context for logging
|
||||
* Creates a summary of bot state for debugging
|
||||
* Intelligently truncate action history while preserving logical chains.
|
||||
* If the first action in the kept list is a 'continue', it backtracks to include
|
||||
* the action that triggered it, ensuring the LLM has full context of its sequence.
|
||||
*/
|
||||
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),
|
||||
export function trimActions(
|
||||
actions: { action: Action, result: unknown }[],
|
||||
max: number,
|
||||
keep: number,
|
||||
): { action: Action, result: unknown }[] {
|
||||
if (actions.length <= max) {
|
||||
return actions
|
||||
}
|
||||
|
||||
if (chatCtx) {
|
||||
context.channelId = chatCtx.channelId
|
||||
context.totalActionsInContext = chatCtx.actions.length
|
||||
let startIndex = actions.length - keep
|
||||
|
||||
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
|
||||
// Backtrack to avoid starting with a 'continue' action which lacks its previous context
|
||||
while (startIndex > 0) {
|
||||
const currentAction = actions[startIndex].action
|
||||
if (currentAction.action === 'continue') {
|
||||
startIndex--
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return context
|
||||
return actions.slice(startIndex)
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ async function main() {
|
||||
|
||||
// Create Satori client
|
||||
const satoriClient = new SatoriClient({
|
||||
url: config.satori.wsUrl,
|
||||
token: config.satori.token,
|
||||
apiBaseUrl: config.satori.apiBaseUrl,
|
||||
token: config.satori.token,
|
||||
url: config.satori.wsUrl,
|
||||
})
|
||||
|
||||
// Create bot context
|
||||
|
||||
@@ -27,33 +27,20 @@ export async function initDb() {
|
||||
await migrate(db, { migrationsFolder: migrationsPath })
|
||||
}
|
||||
|
||||
export const { channels, messages, eventQueue, unreadEvents } = schema
|
||||
export const { channels, eventQueue, messages, unreadEvents } = schema
|
||||
|
||||
export async function recordChannel(id: string, name: string, platform: string, selfId: string) {
|
||||
await db.insert(channels)
|
||||
.values({ id, name, platform, selfId })
|
||||
.onConflictDoUpdate({
|
||||
target: channels.id,
|
||||
set: { name, platform, selfId },
|
||||
})
|
||||
export async function clearEventQueue() {
|
||||
await db.delete(eventQueue)
|
||||
}
|
||||
|
||||
export async function listChannels() {
|
||||
return await db.select().from(channels)
|
||||
export async function clearUnreadEventsForChannel(channelId: string) {
|
||||
await db.delete(unreadEvents).where(eq(unreadEvents.channelId, channelId))
|
||||
}
|
||||
|
||||
export async function recordMessage(channelId: string, userId: string, userName: string, content: string, timestamp?: number) {
|
||||
const ts = timestamp || Date.now()
|
||||
const id = nanoid()
|
||||
|
||||
await db.insert(messages).values({
|
||||
id,
|
||||
channelId,
|
||||
userId,
|
||||
userName,
|
||||
content,
|
||||
timestamp: ts,
|
||||
})
|
||||
export async function deleteUnreadEventsByIds(channelId: string, ids: string[]) {
|
||||
if (ids.length === 0)
|
||||
return
|
||||
await db.delete(unreadEvents).where(inArray(unreadEvents.id, ids))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,90 +57,19 @@ export async function getRecentMessages(channelId: string, limit: number = 10) {
|
||||
|
||||
// Event Queue Persistence
|
||||
|
||||
export async function pushToEventQueue(item: { event: SatoriEvent, status: 'pending' | 'ready' }) {
|
||||
const id = nanoid()
|
||||
await db.insert(eventQueue).values({
|
||||
id,
|
||||
event: item.event,
|
||||
status: item.status,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
export async function removeFromEventQueue(id: string) {
|
||||
await db.delete(eventQueue).where(eq(eventQueue.id, id))
|
||||
}
|
||||
|
||||
export async function clearEventQueue() {
|
||||
await db.delete(eventQueue)
|
||||
}
|
||||
|
||||
export async function saveEventQueue(queue: { id?: string, event: SatoriEvent, status: 'pending' | 'ready' }[]) {
|
||||
// If we have IDs, we might be able to do something smarter, but for now let's just keep it as is
|
||||
// but optimized for the common case where we might want to just replace all.
|
||||
// Actually, the best way to handle this is to NOT use saveEventQueue for single items.
|
||||
await db.delete(eventQueue)
|
||||
if (queue.length > 0) {
|
||||
await db.insert(eventQueue).values(queue.map(item => ({
|
||||
id: item.id || nanoid(),
|
||||
event: item.event,
|
||||
status: item.status,
|
||||
createdAt: Date.now(),
|
||||
})))
|
||||
}
|
||||
export async function listChannels() {
|
||||
return await db.select().from(channels)
|
||||
}
|
||||
|
||||
export async function loadEventQueue() {
|
||||
const result = await db.select().from(eventQueue).orderBy(schema.eventQueue.createdAt)
|
||||
return result.map(r => ({
|
||||
id: r.id,
|
||||
event: r.event as SatoriEvent,
|
||||
id: r.id,
|
||||
status: r.status as 'pending' | 'ready',
|
||||
}))
|
||||
}
|
||||
|
||||
// Unread Events Persistence
|
||||
|
||||
export async function pushToUnreadEvents(channelId: string, event: SatoriEvent) {
|
||||
const id = nanoid()
|
||||
await db.insert(unreadEvents).values({
|
||||
id,
|
||||
channelId,
|
||||
event,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
export async function deleteUnreadEventsByIds(channelId: string, ids: string[]) {
|
||||
if (ids.length === 0)
|
||||
return
|
||||
await db.delete(unreadEvents).where(inArray(unreadEvents.id, ids))
|
||||
}
|
||||
|
||||
export async function clearUnreadEventsForChannel(channelId: string) {
|
||||
await db.delete(unreadEvents).where(eq(unreadEvents.channelId, channelId))
|
||||
}
|
||||
|
||||
export async function saveUnreadEvents(allUnread: Record<string, StoredUnreadEvent[]>) {
|
||||
await db.delete(unreadEvents)
|
||||
const values = []
|
||||
for (const [channelId, events] of Object.entries(allUnread)) {
|
||||
for (const item of events) {
|
||||
values.push({
|
||||
id: item.id || nanoid(),
|
||||
channelId,
|
||||
event: item.event,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (values.length > 0) {
|
||||
await db.insert(unreadEvents).values(values)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadUnreadEvents() {
|
||||
const result = await db.select().from(unreadEvents).orderBy(schema.unreadEvents.createdAt)
|
||||
const allUnread: Record<string, StoredUnreadEvent[]> = {}
|
||||
@@ -162,9 +78,93 @@ export async function loadUnreadEvents() {
|
||||
allUnread[r.channelId] = []
|
||||
}
|
||||
allUnread[r.channelId].push({
|
||||
id: r.id,
|
||||
event: r.event as SatoriEvent,
|
||||
id: r.id,
|
||||
})
|
||||
}
|
||||
return allUnread
|
||||
}
|
||||
|
||||
export async function pushToEventQueue(item: { event: SatoriEvent, status: 'pending' | 'ready' }) {
|
||||
const id = nanoid()
|
||||
await db.insert(eventQueue).values({
|
||||
createdAt: Date.now(),
|
||||
event: item.event,
|
||||
id,
|
||||
status: item.status,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
export async function pushToUnreadEvents(channelId: string, event: SatoriEvent) {
|
||||
const id = nanoid()
|
||||
await db.insert(unreadEvents).values({
|
||||
channelId,
|
||||
createdAt: Date.now(),
|
||||
event,
|
||||
id,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// Unread Events Persistence
|
||||
|
||||
export async function recordChannel(id: string, name: string, platform: string, selfId: string) {
|
||||
await db.insert(channels)
|
||||
.values({ id, name, platform, selfId })
|
||||
.onConflictDoUpdate({
|
||||
set: { name, platform, selfId },
|
||||
target: channels.id,
|
||||
})
|
||||
}
|
||||
|
||||
export async function recordMessage(channelId: string, userId: string, userName: string, content: string, timestamp?: number) {
|
||||
const ts = timestamp || Date.now()
|
||||
const id = nanoid()
|
||||
|
||||
await db.insert(messages).values({
|
||||
channelId,
|
||||
content,
|
||||
id,
|
||||
timestamp: ts,
|
||||
userId,
|
||||
userName,
|
||||
})
|
||||
}
|
||||
|
||||
export async function removeFromEventQueue(id: string) {
|
||||
await db.delete(eventQueue).where(eq(eventQueue.id, id))
|
||||
}
|
||||
|
||||
export async function saveEventQueue(queue: { event: SatoriEvent, id?: string, status: 'pending' | 'ready' }[]) {
|
||||
// If we have IDs, we might be able to do something smarter, but for now let's just keep it as is
|
||||
// but optimized for the common case where we might want to just replace all.
|
||||
// Actually, the best way to handle this is to NOT use saveEventQueue for single items.
|
||||
await db.delete(eventQueue)
|
||||
if (queue.length > 0) {
|
||||
await db.insert(eventQueue).values(queue.map(item => ({
|
||||
createdAt: Date.now(),
|
||||
event: item.event,
|
||||
id: item.id || nanoid(),
|
||||
status: item.status,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveUnreadEvents(allUnread: Record<string, StoredUnreadEvent[]>) {
|
||||
await db.delete(unreadEvents)
|
||||
const values = []
|
||||
for (const [channelId, events] of Object.entries(allUnread)) {
|
||||
for (const item of events) {
|
||||
values.push({
|
||||
channelId,
|
||||
createdAt: Date.now(),
|
||||
event: item.event,
|
||||
id: item.id || nanoid(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (values.length > 0) {
|
||||
await db.insert(unreadEvents).values(values)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ export const channels = pgTable('channels', {
|
||||
})
|
||||
|
||||
export const messages = pgTable('messages', {
|
||||
id: text('id').primaryKey(),
|
||||
channelId: text('channel_id').notNull(),
|
||||
content: text('content').notNull(),
|
||||
id: text('id').primaryKey(),
|
||||
timestamp: bigint('timestamp', { mode: 'number' }).notNull(),
|
||||
userId: text('user_id').notNull(),
|
||||
userName: text('user_name').notNull(),
|
||||
content: text('content').notNull(),
|
||||
timestamp: bigint('timestamp', { mode: 'number' }).notNull(),
|
||||
}, (table) => {
|
||||
return [
|
||||
index('channel_timestamp_idx').on(table.channelId, table.timestamp),
|
||||
@@ -21,15 +21,15 @@ export const messages = pgTable('messages', {
|
||||
})
|
||||
|
||||
export const eventQueue = pgTable('event_queue', {
|
||||
id: text('id').primaryKey(),
|
||||
event: json('event').notNull(),
|
||||
status: text('status').notNull(), // 'pending' | 'ready'
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
|
||||
event: json('event').notNull(),
|
||||
id: text('id').primaryKey(),
|
||||
status: text('status').notNull(), // 'pending' | 'ready'
|
||||
})
|
||||
|
||||
export const unreadEvents = pgTable('unread_events', {
|
||||
id: text('id').primaryKey(),
|
||||
channelId: text('channel_id').notNull(),
|
||||
event: json('event').notNull(),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
|
||||
event: json('event').notNull(),
|
||||
id: text('id').primaryKey(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user