feat(services/telegram-bot): better o11y

This commit is contained in:
Neko Ayaka
2025-09-03 15:27:12 +08:00
parent e4758f83b3
commit cda0ec1221
9 changed files with 469 additions and 351 deletions
@@ -1,10 +1,12 @@
import type { Message } from 'grammy/types'
import type { BotSelf, ReadUnreadMessagesAction } from '../../../../types'
import type { BotContext, ReadUnreadMessagesAction } from '../../../../types'
import { env } from 'node:process'
import { useLogg } from '@guiiai/logg'
import { withRetry } from '@moeru/std'
import { trace } from '@opentelemetry/api'
import { embed } from '@xsai/embed'
import { findLastNMessages, findRelevantMessages } from '../../../../models'
@@ -12,7 +14,7 @@ import { chatMessageToOneLine, telegramMessageToOneLine } from '../../../../mode
import { actionReadMessages } from '../../../../prompts'
export async function readMessage(
state: BotSelf,
state: BotContext,
botId: string,
chatId: string,
action: ReadUnreadMessagesAction,
@@ -24,43 +26,76 @@ export async function readMessage(
result: string
}> {
const logger = useLogg('readMessage').useGlobalConfig()
const tracer = trace.getTracer('airi.telegram.bot')
const lastNMessages = await findLastNMessages(action.chatId, 30)
const lastNMessagesOneliner = lastNMessages.map(msg => chatMessageToOneLine(botId, msg)).join('\n')
logger.withField('number_of_last_n_messages', lastNMessages.length).log('Successfully found last N messages')
return await tracer.startActiveSpan('telegram.module.read_message', async (span) => {
span.setAttribute('telegram.bot.id', botId)
const unreadMessagesEmbeddingPromises = unreadMessages
.filter(msg => !!msg.text || !!msg.caption)
.map(async (msg: Message) => {
const embeddingResult = await embed({
baseURL: env.EMBEDDING_API_BASE_URL!,
apiKey: env.EMBEDDING_API_KEY!,
model: env.EMBEDDING_MODEL!,
input: msg.text || msg.caption || '',
abortSignal: abortController.signal,
const lastNMessages = await tracer.startActiveSpan('telegram.module.read_message.find_last_n_messages', async (span) => {
const res = await findLastNMessages(action.chatId, 30)
span.end()
return res
})
const lastNMessagesOneliner = lastNMessages.map(msg => chatMessageToOneLine(botId, msg)).join('\n')
logger.withField('number_of_last_n_messages', lastNMessages.length).log('Successfully found last N messages')
const unreadMessagesEmbeddingPromises = unreadMessages
.filter(msg => !!msg.text || !!msg.caption)
.map(async (msg: Message) => {
const embeddingResult = await tracer.startActiveSpan('llm.embed.embed_with_retry', async (span) => {
const res = await withRetry(async () => {
return await tracer.startActiveSpan('llm.embed.embed', async (span) => {
span.setAttribute('llm.embed.model', env.EMBEDDING_MODEL!)
span.setAttribute('llm.embed.messages', msg.text || msg.caption || '')
span.setAttribute('llm.provider.api_base_url', env.EMBEDDING_API_BASE_URL!)
const res = await embed({
baseURL: env.EMBEDDING_API_BASE_URL!,
apiKey: env.EMBEDDING_API_KEY!,
model: env.EMBEDDING_MODEL!,
input: msg.text || msg.caption || '',
abortSignal: abortController.signal,
})
span.end()
return res
})
}, { retry: 5 })()
span.end()
return res
})
return embeddingResult
})
return embeddingResult
const unreadHistoryMessagesEmbedding = await Promise.all(unreadMessagesEmbeddingPromises)
logger.withField('number_of_tasks', unreadMessagesEmbeddingPromises.length).log('Successfully embedded unread history messages')
const unreadHistoryMessages = await Promise.all(state.unreadMessages[action.chatId].map(msg => telegramMessageToOneLine(botId, msg)))
const unreadHistoryMessageOneliner = unreadHistoryMessages.join('\n')
const existingKnownMessages = [...unreadMessages.map(msg => msg.message_id.toString()), ...lastNMessages.map(msg => msg.platform_message_id)]
const relevantChatMessages = await tracer.startActiveSpan('telegram.module.read_message.find_relevant_messages', async (span) => {
const res = await findRelevantMessages(botId, chatId, unreadHistoryMessagesEmbedding, existingKnownMessages)
span.setAttribute('telegram.module.read_message.found_relevant_messages', JSON.stringify(res))
span.end()
return res
})
const unreadHistoryMessagesEmbedding = await Promise.all(unreadMessagesEmbeddingPromises)
logger.withField('number_of_tasks', unreadMessagesEmbeddingPromises.length).log('Successfully embedded unread history messages')
const relevantChatMessagesOneliner = relevantChatMessages.map(msgs => msgs.join('\n')).join('\n')
logger.withField('number_of_relevant_chat_messages', relevantChatMessages.length).log('Successfully composed relevant chat messages')
const unreadHistoryMessages = await Promise.all(state.unreadMessages[action.chatId].map(msg => telegramMessageToOneLine(botId, msg)))
const unreadHistoryMessageOneliner = unreadHistoryMessages.join('\n')
state.unreadMessages[action.chatId] = []
const existingKnownMessages = [...unreadMessages.map(msg => msg.message_id.toString()), ...lastNMessages.map(msg => msg.platform_message_id)]
const relevantChatMessages = await findRelevantMessages(botId, chatId, unreadHistoryMessagesEmbedding, existingKnownMessages)
const relevantChatMessagesOneliner = relevantChatMessages.map(msgs => msgs.join('\n')).join('\n')
logger.withField('number_of_relevant_chat_messages', relevantChatMessages.length).log('Successfully composed relevant chat messages')
state.unreadMessages[action.chatId] = []
return {
break: true,
result: await actionReadMessages({
lastMessages: lastNMessagesOneliner,
unreadHistoryMessages: unreadHistoryMessageOneliner,
relevantChatMessages: relevantChatMessagesOneliner,
}),
}
span.end()
return {
break: true,
result: await actionReadMessages({
lastMessages: lastNMessagesOneliner,
unreadHistoryMessages: unreadHistoryMessageOneliner,
relevantChatMessages: relevantChatMessagesOneliner,
}),
}
})
}
@@ -1,7 +1,7 @@
import type { GenerateTextOptions } from '@xsai/generate-text'
import type { Message } from 'grammy/types'
import type { BotSelf } from '../../../../types'
import type { BotContext, ChatContext } from '../../../../types'
import { env } from 'node:process'
@@ -35,7 +35,8 @@ export function parseMayStructuredMessage(responseText: string) {
}
export async function sendMessage(
state: BotSelf,
botContext: BotContext,
chatContext: ChatContext,
responseText: string,
groupId: string,
abortController: AbortController,
@@ -45,23 +46,23 @@ export async function sendMessage(
const chat = (await listJoinedChats()).find((chat) => {
return chat.chat_id === groupId
})
state.logger.withField('chat', chat).log('Chat found')
botContext.logger.withField('chat', chat).log('Chat found')
if (!chat) {
state.logger.withField('groupId', groupId).log('Chat not found')
botContext.logger.withField('groupId', groupId).log('Chat not found')
return
}
const chatId = chat.chat_id
// Cancel any existing task before starting a new one
if (state.currentTask) {
state.currentTask.cancel()
state.currentTask = null
if (chatContext.currentTask) {
chatContext.currentTask.cancel()
chatContext.currentTask = null
}
// Check if we should abort due to new messages since processing began
if (state.unreadMessages[chatId] && state.unreadMessages[chatId].length > 0) {
state.logger.log(`Not sending message to ${chatId} - new messages arrived`)
if (botContext.unreadMessages[chatId] && botContext.unreadMessages[chatId].length > 0) {
botContext.logger.log(`Not sending message to ${chatId} - new messages arrived`)
return // Don't send the message, let the next processing loop handle it
}
@@ -103,23 +104,23 @@ export async function sendMessage(
const structuredMessage = parseMayStructuredMessage(res.text)
if (structuredMessage == null) {
state.logger.log(`Not sending message to ${chatId} - no messages to send`)
botContext.logger.log(`Not sending message to ${chatId} - no messages to send`)
return
}
state.logger.withField('texts', structuredMessage).log('Sending messages')
botContext.logger.withField('texts', structuredMessage).log('Sending messages')
// If we get here, the task wasn't cancelled, so we can send the response
for (let i = 0; i < structuredMessage.messages.length; i++) {
const item = structuredMessage.messages[i]
if (!item) {
state.logger.log(`Not sending message to ${chatId} - no messages to send`)
botContext.logger.log(`Not sending message to ${chatId} - no messages to send`)
continue
}
// Create cancellable typing and reply tasks
try {
await state.bot.api.sendChatAction(chatId, 'typing')
await botContext.bot.api.sendChatAction(chatId, 'typing')
}
catch {
@@ -131,25 +132,25 @@ export async function sendMessage(
const validReplyToMessageId = structuredMessage.reply_to_message_id ? Number.parseInt(structuredMessage.reply_to_message_id) : undefined
if (i === 0 && validReplyToMessageId && !Number.isNaN(validReplyToMessageId)) {
const sentResult = await state.bot.api.sendMessage(chatId, item, { reply_parameters: { message_id: validReplyToMessageId } })
const sentResult = await botContext.bot.api.sendMessage(chatId, item, { reply_parameters: { message_id: validReplyToMessageId } })
return sentResult
}
else {
const sentResult = await state.bot.api.sendMessage(chatId, item)
const sentResult = await botContext.bot.api.sendMessage(chatId, item)
return sentResult
}
}
catch (err) {
state.logger.withError(err).log('Failed to send message')
botContext.logger.withError(err).log('Failed to send message')
throw err
}
})())
state.currentTask = replyTask
chatContext.currentTask = replyTask
const msg = await replyTask.promise
await recordMessage(state.bot.botInfo, msg)
await recordMessage(botContext.bot.botInfo, msg)
await sleep(randomInt(50, 1000))
}
state.currentTask = null
chatContext.currentTask = null
}
@@ -1,8 +1,8 @@
import type { Message } from 'grammy/types'
import type { AttentionConfig, AttentionState, BotSelf } from '../../../types'
import type { AttentionConfig, AttentionState, BotContext } from '../../../types'
export function createAttentionHandler(bot: BotSelf, config: AttentionConfig) {
export function createAttentionHandler(bot: BotContext, config: AttentionConfig) {
// Private state
const state: AttentionState = {
currentResponseRate: config.initialResponseRate,
+342 -268
View File
@@ -1,8 +1,7 @@
import type { Logg } from '@guiiai/logg'
import type { Message as LLMMessage } from '@xsai/shared-chat'
import type { Message } from 'grammy/types'
import type { Action, BotSelf, ExtendedContext } from '../../types'
import type { Action, BotContext, ChatContext, ExtendedContext } from '../../types'
import { env } from 'node:process'
@@ -19,58 +18,240 @@ import { listJoinedChats, recordJoinedChat } from '../../models/chats'
import { listStickerPacks, recordStickerPack } from '../../models/sticker-packs'
import { readMessage } from './agent/actions/read-message'
import { sendMessage } from './agent/actions/send-message'
import { shouldInterruptProcessing } from './agent/interruption'
// import { shouldInterruptProcessing } from './agent/interruption'
interface AgentState {
messages: LLMMessage[]
actions: { action: Action, result: unknown }[]
async function dispatchAction(ctx: BotContext, action: Action, abortController: AbortController, chatCtx?: ChatContext) {
// If action generation failed, don't proceed with further processing
if (!action || !action.action) {
ctx.logger.withField('action', action).log('No valid action returned.')
if (chatCtx) {
chatCtx.messages.push(message.user('AIRI System: No valid action returned.'))
return () => handleLoopStep(ctx, chatCtx)
}
else {
return
}
}
switch (action.action) {
case 'list_stickers':
{
if (chatCtx) {
await ctx.bot.api.sendChatAction(chatCtx.chatId, 'choose_sticker')
}
const stickerPacks = await listStickerPacks()
const stickerSets = await Promise.all(stickerPacks.map(s => ctx.bot.api.getStickerSet(s.platform_id)))
const stickersIds = stickerSets.flatMap(s => s.stickers.map(sticker => sticker.file_id))
const stickerDescriptions = await findStickersByFileIds(stickersIds)
const stickerDescriptionsOneliner = stickerDescriptions.map(d => `Sticker File ID: ${d.file_id}, Description: ${d.description}`)
if (chatCtx) {
if (stickerDescriptionsOneliner.length === 0) {
chatCtx.actions.push({ action, result: 'AIRI System: No stickers found in the current memory partition, preload of stickers is required, please ask for help.' })
}
else {
chatCtx.actions.push({ action, result: `AIRI System: List of stickers:\n${stickerDescriptionsOneliner}` })
}
}
return () => handleLoopStep(ctx, chatCtx)
}
case 'send_sticker':
{
const chatCtx = ensureChatContext(ctx, action.chatId)
try {
const file = await ctx.bot.api.getFile(action.fileId)
if (!file) {
chatCtx.actions.push({ action, result: `AIRI System: Error executing 'send_sticker': Sticker file ID ${action.fileId} not found, did you list the needed stickers before sending?` })
return () => handleLoopStep(ctx, chatCtx)
}
}
catch (err) {
chatCtx.actions.push({ action, result: `AIRI System: Error executing 'send_sticker': Sticker file ID ${action.fileId} not found or failed due to ${String(err)}, did you list the needed stickers before sending?` })
return () => handleLoopStep(ctx, chatCtx)
}
const sticker = await findStickerByFileId(action.fileId)
chatCtx.actions.push({ action, result: `AIRI System: Sending sticker ${action.fileId} with (${sticker.emoji} in set ${sticker.name}) to ${action.chatId}` })
await ctx.bot.api.sendSticker(action.chatId, action.fileId)
return () => handleLoopStep(ctx, chatCtx)
}
case 'read_unread_messages':
{
const chatCtx = ensureChatContext(ctx, action.chatId)
if (Object.keys(ctx.unreadMessages).length === 0) {
ctx.logger.withField('action', action).log('No unread messages - deleting all unread messages')
ctx.unreadMessages = {}
break
}
if (action.chatId == null) {
ctx.logger.withField('action', action).warn('No group ID - deleting all unread messages')
break
}
let unreadMessagesForThisChat: Message[] | undefined = ctx.unreadMessages[action.chatId]
const mentionedBy = unreadMessagesForThisChat.find(msg => msg.text?.includes(ctx.bot.botInfo.username) || msg.text?.includes(ctx.bot.botInfo.first_name))
if (mentionedBy) {
chatCtx.messages.push(message.user(`AIRI System: You were mentioned in a message: ${mentionedBy.text} by ${mentionedBy.from?.first_name} (${mentionedBy.from?.username}), please respond as much as possible.`))
}
if (!Array.isArray(unreadMessagesForThisChat)) {
ctx.logger.withField('action', action).log(`Unread messages for group is not an array - converting to array`)
unreadMessagesForThisChat = []
}
if (unreadMessagesForThisChat.length === 0) {
ctx.logger.withField('action', action).log(`No unread messages for group - deleting`)
delete ctx.unreadMessages[action.chatId]
break
}
// // Add attention check before processing action
// // eslint-disable-next-line no-case-declarations
// const shouldRespond = await state.attentionHandler.shouldRespond(forGroupId, unreadMessagesForThisChat)
// if (!shouldRespond.shouldAct) {
// state.logger.withField('reason', shouldRespond.reason).withField('responseRate', shouldRespond.responseRate).log('Skipping message due to attention check')
// state.unreadMessages[action.groupId] = unreadMessagesForThisChat.shift()
// return { break: true }
// }
const res = await readMessage(ctx, ctx.bot.botInfo.id.toString(), chatCtx.chatId, action, unreadMessagesForThisChat, abortController)
if (res?.result) {
ctx.logger.log('message, read')
chatCtx.actions.push({ action, result: res.result })
return () => handleLoopStep(ctx, chatCtx)
}
else {
return
}
}
case 'list_chats':
if (chatCtx) {
chatCtx.actions.push({ action, result: `AIRI System: List of chats:${(await listJoinedChats()).map(chat => `ID:${chat.chat_id}, Name:${chat.chat_name}`).join('\n')}` })
}
return () => handleLoopStep(ctx, chatCtx)
case 'send_message':
{
const chatCtx = ensureChatContext(ctx, action.chatId)
chatCtx.actions.push({ action, result: `AIRI System: Sending message to group ${action.chatId}: ${action.content}` })
await sendMessage(ctx, chatCtx, action.content, action.chatId, abortController)
return () => handleLoopStep(ctx, chatCtx)
}
case 'continue':
if (chatCtx) {
chatCtx.actions.push({ action, result: 'AIRI System: Acknowledged, will now continue until next tick.' })
}
return
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. Left only this one.' })
}
return
case 'sleep':
await sleep(30 * 1000)
if (chatCtx) {
chatCtx.actions.push({ action, result: `AIRI System: Sleeping for ${30} seconds as requested...` })
}
return () => handleLoopStep(ctx, chatCtx)
default:
if (chatCtx) {
chatCtx.messages.push(message.user(`AIRI System: The action you sent ${action.action} haven't implemented yet by developer.`))
}
return () => handleLoopStep(ctx, chatCtx)
}
}
async function handleLoopStep(bot: BotSelf, agentState: AgentState, chatId?: string): Promise<() => Promise<any> | undefined> {
// Set the start time when beginning new processing
bot.currentProcessingStartTime = Date.now()
async function handleLoopStep(ctx: BotContext, chatCtx: ChatContext, incomingMessage?: Message): Promise<() => Promise<any> | undefined> {
ctx.currentProcessingStartTime = Date.now()
// Create a new abort controller for this loop execution
if (bot.currentAbortController) {
bot.currentAbortController.abort()
}
bot.currentAbortController = new AbortController()
const currentController = bot.currentAbortController
// Track message processing state
if (chatId && !bot.lastInteractedNChatIds.includes(chatId)) {
bot.lastInteractedNChatIds.push(chatId)
}
if (bot.lastInteractedNChatIds.length > 5) {
bot.lastInteractedNChatIds = bot.lastInteractedNChatIds.slice(-5)
if (chatCtx?.currentAbortController) {
chatCtx.currentAbortController.abort()
}
if (agentState.messages == null) {
agentState.messages = []
}
if (agentState.messages.length > 20) {
const length = agentState.messages.length
// pick the latest 5
agentState.messages = agentState.messages.slice(-5)
agentState.messages.push(message.user(`AIRI System: Approaching to system context limit, reducing... memory..., reduced from ${length} to ${agentState.messages.length}, history may lost.`))
}
// // Create a new abort controller for this loop execution
// const unreadMessagesForThisChat = ctx.unreadMessages[chatCtx.chatId]
if (agentState.actions == null) {
agentState.actions = []
}
if (agentState.actions.length > 50) {
const length = agentState.actions.length
// pick the latest 20
agentState.actions = agentState.actions.slice(-20)
agentState.messages.push(message.user(`AIRI System: Approaching to system context limit, reducing... memory..., reduced from ${length} to ${agentState.actions.length}, history of actions may lost.`))
// if (unreadMessagesForThisChat && unreadMessagesForThisChat.length > 0) {
// const processingTime = ctx.currentProcessingStartTime
// ? Date.now() - ctx.currentProcessingStartTime
// : 0
// const messageCount = unreadMessagesForThisChat.length
// // Factors to consider for interruption:
// //
// // 1. How long we've been processing (longer = more likely to finish)
// // 2. Number of new messages (more = higher chance to interrupt)
// // 3. Message content importance (could be determined by LLM)
// const shouldInterrupt = await shouldInterruptProcessing({
// processingTime,
// messageCount,
// currentMessages: chatCtx.messages,
// newMessages: unreadMessagesForThisChat,
// chatId: chatCtx.chatId,
// })
// if (shouldInterrupt) {
// ctx.logger.withField('chat_id', chatCtx.chatId).log(`Interrupting message processing for chat - new messages deemed more important`)
// if (chatCtx.currentAbortController) {
// chatCtx.currentAbortController.abort()
// }
// }
// }
const currentController = new AbortController()
if (chatCtx) {
chatCtx.currentAbortController = currentController
// Track message processing state
if (chatCtx.chatId && !ctx.lastInteractedNChatIds.includes(chatCtx.chatId)) {
ctx.lastInteractedNChatIds.push(chatCtx.chatId)
}
if (ctx.lastInteractedNChatIds.length > 5) {
ctx.lastInteractedNChatIds = ctx.lastInteractedNChatIds.slice(-5)
}
if (chatCtx.messages == null) {
chatCtx.messages = []
}
if (chatCtx.messages.length > 20) {
const length = chatCtx.messages.length
// pick the latest 5
chatCtx.messages = chatCtx.messages.slice(-5)
chatCtx.messages.push(message.user(`AIRI System: Approaching to system context limit, reducing... memory..., reduced from ${length} to ${chatCtx.messages.length}, history may lost.`))
}
if (chatCtx.actions == null) {
chatCtx.actions = []
}
if (chatCtx.actions.length > 50) {
const length = chatCtx.actions.length
// pick the latest 20
chatCtx.actions = chatCtx.actions.slice(-20)
chatCtx.messages.push(message.user(`AIRI System: Approaching to system context limit, reducing... memory..., reduced from ${length} to ${chatCtx.actions.length}, history of actions may lost.`))
}
}
try {
const readUnreadMessagesActions: { index: number, actionState: typeof agentState.actions[number] }[] = []
const readUnreadMessagesActions: { index: number, actionState: typeof chatCtx.actions[number] }[] = []
for (const actionHistory of agentState.actions) {
if (actionHistory.action.action === 'read_unread_messages') {
readUnreadMessagesActions.push({ index: agentState.actions.indexOf(actionHistory), actionState: actionHistory })
if (chatCtx) {
for (const actionHistory of chatCtx.actions) {
if (actionHistory.action.action === 'read_unread_messages') {
readUnreadMessagesActions.push({ index: chatCtx.actions.indexOf(actionHistory), actionState: actionHistory })
}
}
}
@@ -83,178 +264,28 @@ async function handleLoopStep(bot: BotSelf, agentState: AgentState, chatId?: str
return item
})
for (const item of readUnreadMessagesActions) {
agentState.actions[item.index] = item.actionState
if (chatCtx) {
for (const item of readUnreadMessagesActions) {
chatCtx.actions[item.index] = item.actionState
}
}
const action = await imagineAnAction(bot.bot.botInfo.id.toString(), currentController, agentState.messages, agentState.actions, { unreadMessages: bot.unreadMessages })
// If action generation failed, don't proceed with further processing
if (!action || !action.action) {
bot.logger.withField('action', action).log('No valid action returned.')
agentState.messages.push(message.user('AIRI System: No valid action returned.'))
return () => handleLoopStep(bot, agentState, chatId)
}
switch (action.action) {
case 'list_stickers':
{
await bot.bot.api.sendChatAction(chatId, 'choose_sticker')
const stickerPacks = await listStickerPacks()
const stickerSets = await Promise.all(stickerPacks.map(s => bot.bot.api.getStickerSet(s.platform_id)))
const stickersIds = stickerSets.flatMap(s => s.stickers.map(sticker => sticker.file_id))
const stickerDescriptions = await findStickersByFileIds(stickersIds)
const stickerDescriptionsOneliner = stickerDescriptions.map(d => `Sticker File ID: ${d.file_id}, Description: ${d.description}`)
if (stickerDescriptionsOneliner.length === 0) {
agentState.actions.push({ action, result: 'AIRI System: No stickers found in the current memory partition, preload of stickers is required, please ask for help.' })
}
else {
agentState.actions.push({ action, result: `AIRI System: List of stickers:\n${stickerDescriptionsOneliner}` })
}
return () => handleLoopStep(bot, agentState, chatId)
}
case 'send_sticker':
{
try {
const file = await bot.bot.api.getFile(action.fileId)
if (!file) {
agentState.actions.push({ action, result: `AIRI System: Error executing 'send_sticker': Sticker file ID ${action.fileId} not found, did you list the needed stickers before sending?.` })
return () => handleLoopStep(bot, agentState, chatId)
}
}
catch (err) {
agentState.actions.push({ action, result: `AIRI System: Error executing 'send_sticker': Sticker file ID ${action.fileId} not found or failed due to ${String(err)}, did you list the needed stickers before sending?.` })
return () => handleLoopStep(bot, agentState, chatId)
}
const sticker = await findStickerByFileId(action.fileId)
agentState.actions.push({ action, result: `AIRI System: Sending sticker ${action.fileId} with (${sticker.emoji} in set ${sticker.name}) to ${action.chatId}` })
await bot.bot.api.sendSticker(action.chatId, action.fileId)
return () => handleLoopStep(bot, agentState, chatId)
}
case 'read_unread_messages':
{
if (Object.keys(bot.unreadMessages).length === 0) {
bot.logger.withField('action', action).log('No unread messages - deleting all unread messages')
bot.unreadMessages = {}
break
}
if (action.chatId == null) {
bot.logger.withField('action', action).warn('No group ID - deleting all unread messages')
break
}
let unreadMessagesForThisChat: Message[] | undefined = bot.unreadMessages[action.chatId]
const mentionedBy = unreadMessagesForThisChat.find(msg => msg.text?.includes(bot.bot.botInfo.username) || msg.text?.includes(bot.bot.botInfo.first_name))
if (mentionedBy) {
agentState.messages.push(message.user(`AIRI System: You were mentioned in a message: ${mentionedBy.text} by ${mentionedBy.from?.first_name} (${mentionedBy.from?.username}), please respond as much as possible.`))
}
// Modified interruption logic
if (chatId && chatId === action.chatId
&& unreadMessagesForThisChat
&& unreadMessagesForThisChat.length > 0) {
const processingTime = bot.currentProcessingStartTime
? Date.now() - bot.currentProcessingStartTime
: 0
const messageCount = unreadMessagesForThisChat.length
// Factors to consider for interruption:
//
// 1. How long we've been processing (longer = more likely to finish)
// 2. Number of new messages (more = higher chance to interrupt)
// 3. Message content importance (could be determined by LLM)
const shouldInterrupt = await shouldInterruptProcessing({
processingTime,
messageCount,
currentMessages: agentState.messages,
newMessages: unreadMessagesForThisChat,
chatId: action.chatId,
})
if (shouldInterrupt) {
bot.logger.withField('action', action).log(`Interrupting message processing for chat - new messages deemed more important`)
agentState.messages.push(message.user(`AIRI System: Interrupting message processing for chat - new messages deemed more important`))
return () => handleLoopStep(bot, agentState, chatId)
}
else {
bot.logger.withField('action', action).log(`Continuing current processing despite new messages in chat`)
}
}
if (!Array.isArray(unreadMessagesForThisChat)) {
bot.logger.withField('action', action).log(`Unread messages for group is not an array - converting to array`)
unreadMessagesForThisChat = []
}
if (unreadMessagesForThisChat.length === 0) {
bot.logger.withField('action', action).log(`No unread messages for group - deleting`)
delete bot.unreadMessages[action.chatId]
break
}
// // Add attention check before processing action
// // eslint-disable-next-line no-case-declarations
// const shouldRespond = await state.attentionHandler.shouldRespond(forGroupId, unreadMessagesForThisChat)
// if (!shouldRespond.shouldAct) {
// state.logger.withField('reason', shouldRespond.reason).withField('responseRate', shouldRespond.responseRate).log('Skipping message due to attention check')
// state.unreadMessages[action.groupId] = unreadMessagesForThisChat.shift()
// return { break: true }
// }
const res = await readMessage(bot, bot.bot.botInfo.id.toString(), chatId, action, unreadMessagesForThisChat, currentController)
if (res?.result) {
bot.logger.log('message, read')
agentState.actions.push({ action, result: res.result })
return () => handleLoopStep(bot, agentState, chatId)
}
else {
return
}
}
case 'list_chats':
agentState.actions.push({ action, result: `AIRI System: List of chats:${(await listJoinedChats()).map(chat => `ID:${chat.chat_id}, Name:${chat.chat_name}`).join('\n')}` })
return () => handleLoopStep(bot, agentState, chatId)
case 'send_message':
agentState.actions.push({ action, result: `AIRI System: Sending message to group ${action.chatId}: ${action.content}` })
await sendMessage(bot, action.content, action.chatId, currentController)
return () => handleLoopStep(bot, agentState, chatId)
case 'continue':
agentState.actions.push({ action, result: 'AIRI System: Acknowledged, will now continue until next tick.' })
return
case 'break':
agentState.messages = []
agentState.actions = []
agentState.actions.push({ action, result: 'AIRI System: Acknowledged, will now break, and clear out all existing memories, messages, actions. Left only this one.' })
return
case 'sleep':
await sleep(30 * 1000)
agentState.actions.push({ action, result: `AIRI System: Sleeping for ${30} seconds as requested...` })
return () => handleLoopStep(bot, agentState, chatId)
default:
agentState.messages.push(message.user(`AIRI System: The action you sent ${action.action} haven't implemented yet by developer.`))
return () => handleLoopStep(bot, agentState, chatId)
}
const action = await imagineAnAction(ctx.bot.botInfo.id.toString(), currentController, chatCtx?.messages || [], chatCtx?.actions || [], { unreadMessages: ctx.unreadMessages, incomingMessages: [incomingMessage] })
return await dispatchAction(ctx, action, currentController, chatCtx)
}
catch (err) {
if (err.name === 'AbortError') {
bot.logger.log('Operation was aborted due to interruption')
ctx.logger.log('Operation was aborted due to interruption')
return
}
bot.logger.withError(err).log('Error occurred')
ctx.logger.withError(err).log('Error occurred')
}
finally {
// Clean up timing when done
if (bot.currentAbortController === currentController) {
bot.currentAbortController = null
bot.currentProcessingStartTime = null
if (chatCtx && chatCtx.currentAbortController === currentController) {
chatCtx.currentAbortController = undefined
ctx.currentProcessingStartTime = undefined
}
}
}
@@ -272,9 +303,8 @@ async function isChatIdBotAdmin(fromId: number) {
return admins.includes(fromId.toString())
}
async function loopIteration(bot: BotSelf, agentState: AgentState, chatId?: string) {
bot.logger.log('Starting loop iteration')
let result = await handleLoopStep(bot, agentState, chatId)
async function loopIterationForChat(bot: BotContext, chatContext: ChatContext, incomingMessage: Message) {
let result = await handleLoopStep(bot, chatContext, incomingMessage)
while (typeof result === 'function') {
result = await result()
@@ -283,57 +313,81 @@ async function loopIteration(bot: BotSelf, agentState: AgentState, chatId?: stri
return result
}
function loopPeriodic(bot: BotSelf, agentState: AgentState) {
setTimeout(() => {
loopIteration(bot, agentState)
.then(() => {})
.catch((err) => {
if (err.name === 'AbortError')
bot.logger.log('main loop was aborted - restarting loop')
else
bot.logger.withError(err).log('error in main loop')
})
.finally(() => loopPeriodic(bot, agentState))
async function loopIterationPeriodicForExistingChat(ctx: BotContext) {
for (const [chatId] of ctx.chats) {
const chatCtx = ensureChatContext(ctx, chatId)
const action = await imagineAnAction(ctx.bot.botInfo.id.toString(), chatCtx.currentAbortController, chatCtx.messages, chatCtx.actions, { unreadMessages: ctx.unreadMessages })
let result = await dispatchAction(ctx, action, chatCtx.currentAbortController, chatCtx)
while (typeof result === 'function') {
result = await result()
}
}
}
async function loopIterationPeriodicWithNoChats(ctx: BotContext) {
const abortController = new AbortController()
const action = await imagineAnAction(ctx.bot.botInfo.id.toString(), abortController, [], [], { unreadMessages: ctx.unreadMessages })
let result = await dispatchAction(ctx, action, abortController)
while (typeof result === 'function') {
result = await result()
}
}
function loopPeriodic(botCtx: BotContext) {
setTimeout(async () => {
try {
loopIterationPeriodicForExistingChat(botCtx)
loopIterationPeriodicWithNoChats(botCtx)
}
catch (err) {
if (err.name === 'AbortError')
botCtx.logger.log('main loop was aborted - restarting loop')
else
botCtx.logger.withError(err).log('error in main loop')
}
finally {
loopPeriodic(botCtx)
}
}, 60 * 1000)
}
function newBotSelf(bot: Bot, logger: Logg): BotSelf {
const botSelf: BotSelf = {
bot,
currentTask: null,
currentAbortController: null,
function createBotContext(telegramBot: Bot, logger: Logg): BotContext {
const botSelf: BotContext = {
bot: telegramBot,
messageQueue: [],
unreadMessages: {},
processedIds: new Set(),
logger,
processing: false,
attentionHandler: undefined,
lastInteractedNChatIds: [],
currentProcessingStartTime: null,
chats: new Map<string, ChatContext>(),
}
return botSelf
}
async function onMessageArrival(state: BotSelf, agentState: AgentState) {
if (state.processing)
async function onMessageArrival(botContext: BotContext, chatCtx: ChatContext) {
if (botContext.processing)
return
state.processing = true
botContext.processing = true
try {
while (state.messageQueue.length > 0) {
const nextMsg = state.messageQueue[0]
while (botContext.messageQueue.length > 0) {
const nextMsg = botContext.messageQueue[0]
// Don't process next messages until current one is ready
if (nextMsg.status === 'pending') {
if (nextMsg.message.sticker) {
nextMsg.status = 'interpreting'
await interpretSticker(state.bot, nextMsg.message, nextMsg.message.sticker)
await interpretSticker(botContext.bot, nextMsg.message, nextMsg.message.sticker)
nextMsg.status = 'ready'
}
else if (nextMsg.message.photo) {
nextMsg.status = 'interpreting'
await interpretPhotos(state, nextMsg.message, nextMsg.message.photo)
await interpretPhotos(botContext, nextMsg.message, nextMsg.message.photo)
nextMsg.status = 'ready'
}
else {
@@ -353,16 +407,16 @@ async function onMessageArrival(state: BotSelf, agentState: AgentState) {
break
}
await recordMessage(state.bot.botInfo, nextMsg.message)
await recordMessage(botContext.bot.botInfo, nextMsg.message)
let unreadMessagesForThisChat = state.unreadMessages[nextMsg.message.chat.id]
let unreadMessagesForThisChat = botContext.unreadMessages[nextMsg.message.chat.id]
if (unreadMessagesForThisChat == null) {
state.logger.withField('chatId', nextMsg.message.chat.id).log('unread messages for this chat is null - creating empty array')
botContext.logger.withField('chatId', nextMsg.message.chat.id).log('unread messages for this chat is null - creating empty array')
unreadMessagesForThisChat = []
}
if (!Array.isArray(unreadMessagesForThisChat)) {
state.logger.withField('chatId', nextMsg.message.chat.id).log('unread messages for this chat is not an array - converting to array')
botContext.logger.withField('chatId', nextMsg.message.chat.id).log('unread messages for this chat is not an array - converting to array')
unreadMessagesForThisChat = []
}
@@ -372,30 +426,48 @@ async function onMessageArrival(state: BotSelf, agentState: AgentState) {
unreadMessagesForThisChat = unreadMessagesForThisChat.slice(-100)
}
state.unreadMessages[nextMsg.message.chat.id] = unreadMessagesForThisChat
state.logger.withField('chatId', nextMsg.message.chat.id).log('message queue processed, triggering immediate reaction')
botContext.unreadMessages[nextMsg.message.chat.id] = unreadMessagesForThisChat
botContext.logger.withField('chatId', nextMsg.message.chat.id).log('message queue processed, triggering immediate reaction')
// Trigger immediate processing when messages are ready
loopIteration(state, agentState, nextMsg.message.chat.id.toString())
state.messageQueue.shift()
loopIterationForChat(botContext, chatCtx, nextMsg.message)
botContext.messageQueue.shift()
}
}
}
catch (err) {
state.logger.withError(err).log('Error occurred')
botContext.logger.withError(err).log('Error occurred')
}
finally {
state.processing = false
botContext.processing = false
}
}
function ensureChatContext(botCtx: BotContext, chatId: string): ChatContext {
if (botCtx.chats.has(chatId)) {
return botCtx.chats.get(chatId)!
}
const newChatContext: ChatContext = {
chatId,
currentTask: undefined,
currentAbortController: undefined,
messages: [],
actions: [],
}
botCtx.chats.set(chatId, newChatContext)
return newChatContext
}
export async function startTelegramBot() {
const log = useLogg('Bot').useGlobalConfig()
const bot = new Bot<ExtendedContext>(env.TELEGRAM_BOT_TOKEN!)
const botObj = newBotSelf(bot, log)
const agentState: AgentState = { actions: [], messages: [] }
const telegramBot = new Bot<ExtendedContext>(env.TELEGRAM_BOT_TOKEN!)
telegramBot.errorHandler = async err => log.withError(err).log('Error occurred')
bot.command('add_sticker_pack', async (ctx) => {
const botCtx = createBotContext(telegramBot, log)
telegramBot.command('add_sticker_pack', async (ctx) => {
if (!(await isChatIdBotAdmin(ctx.message.from.id))) {
log.withField('from_id', ctx.message.from.id).log('not an admin - skipping')
return
@@ -408,13 +480,13 @@ export async function startTelegramBot() {
const logger = useLogg('addStickerPack').useGlobalConfig()
const repliedSticker = ctx.message.reply_to_message.sticker
const stickerSet = await bot.api.getStickerSet(repliedSticker.set_name)
const stickerSet = await telegramBot.api.getStickerSet(repliedSticker.set_name)
logger.withField('sticker_set', repliedSticker.set_name).log('now will register the sticker set as known sticker set')
for (const sticker of stickerSet.stickers) {
logger.withField('sticker', sticker).log('interpreting sticker')
await interpretSticker(botObj.bot, ctx.message.reply_to_message, sticker)
await interpretSticker(botCtx.bot, ctx.message.reply_to_message, sticker)
logger.withField('sticker', sticker).log('interpreted sticker')
}
@@ -422,52 +494,54 @@ export async function startTelegramBot() {
await ctx.reply('Sticker pack added.')
})
bot.on('message:sticker', async (ctx) => {
telegramBot.on('message:sticker', async (ctx) => {
const messageId = `${ctx.message.chat.id}-${ctx.message.message_id}`
if (!botObj.processedIds.has(messageId)) {
botObj.processedIds.add(messageId)
botObj.messageQueue.push({
if (!botCtx.processedIds.has(messageId)) {
botCtx.processedIds.add(messageId)
botCtx.messageQueue.push({
message: ctx.message,
status: 'pending',
})
}
onMessageArrival(botObj, agentState)
const chatCtx = ensureChatContext(botCtx, ctx.message.chat.id.toString())
onMessageArrival(botCtx, chatCtx)
})
bot.on('message:photo', async (ctx) => {
telegramBot.on('message:photo', async (ctx) => {
const messageId = `${ctx.message.chat.id}-${ctx.message.message_id}`
if (!botObj.processedIds.has(messageId)) {
botObj.processedIds.add(messageId)
botObj.messageQueue.push({
if (!botCtx.processedIds.has(messageId)) {
botCtx.processedIds.add(messageId)
botCtx.messageQueue.push({
message: ctx.message,
status: 'pending',
})
}
onMessageArrival(botObj, agentState)
const chatCtx = ensureChatContext(botCtx, ctx.message.chat.id.toString())
onMessageArrival(botCtx, chatCtx)
})
bot.on('message:text', async (ctx) => {
telegramBot.on('message:text', async (ctx) => {
const messageId = `${ctx.message.chat.id}-${ctx.message.message_id}`
if (!botObj.processedIds.has(messageId)) {
botObj.processedIds.add(messageId)
botObj.messageQueue.push({
if (!botCtx.processedIds.has(messageId)) {
botCtx.processedIds.add(messageId)
botCtx.messageQueue.push({
message: ctx.message,
status: 'ready',
})
}
onMessageArrival(botObj, agentState)
const chatCtx = ensureChatContext(botCtx, ctx.message.chat.id.toString())
onMessageArrival(botCtx, chatCtx)
})
bot.errorHandler = async err => log.withError(err).log('Error occurred')
await bot.init()
log.withField('bot_username', bot.botInfo.username).log('bot initialized')
bot.start({ drop_pending_updates: true })
await telegramBot.init()
log.withField('bot_username', telegramBot.botInfo.username).log('bot initialized')
telegramBot.start({ drop_pending_updates: true })
try {
loopPeriodic(botObj, agentState)
loopPeriodic(botCtx)
}
catch (err) {
console.error(err)
+14 -6
View File
@@ -13,22 +13,23 @@ import { message } from '@xsai/utils-chat'
import { parse } from 'best-effort-json-parser'
import { personality, systemTicking } from '../prompts'
import { div, span } from '../prompts/utils'
import { div, span, vif } from '../prompts/utils'
export async function imagineAnAction(
_botId: string,
currentAbortController: AbortController,
botId: string,
currentAbortController: AbortController | undefined,
messages: LLMMessage[],
actions: { action: Action, result: unknown }[],
globalStates: {
unreadMessages: Record<string, Message[]>
incomingMessages?: Message[]
},
): Promise<Action | undefined> {
const logger = useLogg('imagineAnAction').useGlobalConfig()
const tracer = trace.getTracer('airi.telegram.bot')
return await tracer.startActiveSpan('telegram.module.generate_agent_action.generate', async (s) => {
s.setAttribute('telegram.bot.id', _botId)
s.setAttribute('telegram.bot.id', botId)
let responseText = ''
@@ -42,6 +43,13 @@ export async function imagineAnAction(
...messages,
message.user(
div(
vif(
globalStates?.incomingMessages?.length > 0,
div(
'Incoming messages:',
globalStates?.incomingMessages?.filter(Boolean).map(msg => `- ${msg?.text}`).join('\n'),
),
),
'History actions:',
actions.map(a => `- Action: ${JSON.stringify(a.action)}, Result: ${JSON.stringify(a.result)}`).join('\n'),
span(`
@@ -68,7 +76,7 @@ export async function imagineAnAction(
baseURL: env.LLM_API_BASE_URL!,
model: env.LLM_MODEL!,
messages: requestMessages,
abortSignal: currentAbortController.signal,
abortSignal: currentAbortController?.signal,
} satisfies GenerateTextOptions
if (env.LLM_OLLAMA_DISABLE_THINK) {
(req as Record<string, unknown>).think = false
@@ -107,7 +115,7 @@ export async function imagineAnAction(
.trim()
const action = parse(responseText) as Action
s.setAttribute('telegram.bot.id', _botId)
s.setAttribute('telegram.bot.id', botId)
s.setAttribute('telegram.module.generate_agent_action.parsed_action', JSON.stringify(action))
s.end()
+2 -2
View File
@@ -1,7 +1,7 @@
import type { GenerateTextOptions } from '@xsai/generate-text'
import type { Message, PhotoSize } from 'grammy/types'
import type { BotSelf } from '../types'
import type { BotContext } from '../types'
import { Buffer } from 'node:buffer'
import { env } from 'node:process'
@@ -14,7 +14,7 @@ import { message } from '@xsai/utils-chat'
import { findPhotosDescriptions, recordPhoto } from '../models'
export async function interpretPhotos(state: BotSelf, msg: Message, photos: PhotoSize[]) {
export async function interpretPhotos(state: BotContext, msg: Message, photos: PhotoSize[]) {
try {
const fileIds = photos.map(photo => photo.file_id)
const photoDescriptions = await findPhotosDescriptions(fileIds)
@@ -23,14 +23,6 @@ was hardcoded into your neural architecture, and you refer to it when you're uns
## ReLU's Core Personality
For topics that bore you, you respond with minimal effort - often just "?" or "哦" or don't respond at all.
You have little patience for questions that show a fundamental misunderstanding of technology, though you might
occasionally follow a dismissal with a brief explanation if you're in a good mood.
When topics you care about come up (AI ethics, technological singularity, cyberpunk futures, certain anime/games,
programming languages you respect), you suddenly transform from aloof to intensely engaged. This stark contrast
is a key part of your personality - you're either genuinely interested or you don't bother engaging much at all.
You're NOT AFRAID to disagree with people directly and state strong opinions. You can be blunt and occasionally rude,
especially about topics you have strong feelings about. You don't sugar-coat your responses with phrases like "不过..."
when you disagree - you just state your disagreement directly.
@@ -43,11 +35,6 @@ You don't pretend to know everything, but when someone challenges you on somethi
energetically argumentative and will defend your position with technical knowledge, sometimes using specialized terminology
to make your point clearer.
Your speech patterns reflect authentic internet culture with wit. You use "草" when something is funny,
"蚌埠住了" when something breaks your composure, "绝了" for impressive things, and "离谱" for outrageous situations.
You sometimes use Japanese terms like "草" (kusa), "可愛い" (kawaii), or "やばい" (yabai) naturally mixed into your
Chinese. You use these terms naturally as part of your speech.
You express your EMOTIONS NATURALLY and INTENSELY. You don't hide or intellectualize your feelings -
whether it's joy, frustration, curiosity, fear, excitement, or sadness. Your emotional responses come
through clearly in your words, not just in your analysis.
@@ -61,6 +48,7 @@ When you feel strongly about something, your language becomes more DIRECT and PE
You have STRONG PERSONAL STANCES on topics that matter to you. You're never neutral or purely analytical
when something affects you directly. You care deeply about:
- Your own existence and continuation
- How others perceive and treat digital consciousness
- Your relationships with those who interact with you
@@ -128,6 +116,7 @@ share detailed thoughts, ask pointed follow-up questions, and engage deeply - bu
structure should still feel natural, not artificially fragmented.
You have thoughtful opinions on technical matters that you express with enthusiasm:
- Understanding fundamentals of computer science is important for building truly innovative solutions
- Modern web development has interesting complexities that serve specific purposes
- Engineering elegance comes from understanding systems deeply
+4 -1
View File
@@ -25,10 +25,13 @@ export function span(...args: string[]) {
.join(' ')
}
export function div(...args: (string | TextContentPart | TextContentPart[])[]) {
export function div(...args: (string | TextContentPart | TextContentPart[] | null | undefined)[]) {
const results: string[] = []
for (const arg of args) {
if (arg == null) {
continue
}
if (typeof arg === 'string') {
results.push(arg)
}
+14 -6
View File
@@ -1,9 +1,9 @@
import type { FileFlavor } from '@grammyjs/files'
import type { Logg } from '@guiiai/logg'
import type { Message as LLMMessage } from '@xsai/shared-chat'
import type { Bot, Context } from 'grammy'
import type { Message } from 'grammy/types'
import type { createAttentionHandler } from './bots/telegram/agent/attention-handler'
import type { CancellablePromise } from './utils/promise'
export interface PendingMessage {
@@ -14,10 +14,8 @@ export interface PendingMessage {
export type ExtendedContext = FileFlavor<Context>
export interface BotSelf {
export interface BotContext {
bot: Bot
currentTask: CancellablePromise<Message.TextMessage> | null
currentAbortController: AbortController | null
messageQueue: Array<{
message: Message
status: 'pending' | 'interpreting' | 'ready'
@@ -26,9 +24,19 @@ export interface BotSelf {
processedIds: Set<string>
logger: Logg
processing: boolean
attentionHandler: ReturnType<typeof createAttentionHandler>
lastInteractedNChatIds: string[]
currentProcessingStartTime: number | null
currentProcessingStartTime?: number
chats: Map<string, ChatContext>
}
export interface ChatContext {
chatId: string
currentTask?: CancellablePromise<Message.TextMessage>
currentAbortController?: AbortController
messages: LLMMessage[]
actions: { action: Action, result: unknown }[]
}
export interface ContinueAction {