style: lint

This commit is contained in:
Neko Ayaka
2026-08-26 19:49:58 +08:00
parent e60a04a4ec
commit 98f40d7d0b
1625 changed files with 75216 additions and 75203 deletions
+3 -3
View File
@@ -9,10 +9,10 @@ try {
catch {}
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'postgresql',
dbCredentials: {
url: env.DATABASE_URL!,
},
dialect: 'postgresql',
out: './drizzle',
schema: './src/db/schema.ts',
})
@@ -25,10 +25,10 @@ async function main() {
let messages: typeof chatMessagesTable.$inferSelect[] = []
switch (env.EMBEDDING_DIMENSION) {
case '1536':
case '768':
messages = await db.query.chatMessagesTable.findMany({
where(fields) {
return isNull(fields.content_vector_1536)
return isNull(fields.content_vector_768)
},
})
break
@@ -39,10 +39,10 @@ async function main() {
},
})
break
case '768':
case '1536':
messages = await db.query.chatMessagesTable.findMany({
where(fields) {
return isNull(fields.content_vector_768)
return isNull(fields.content_vector_1536)
},
})
break
@@ -53,24 +53,24 @@ async function main() {
// Split messages into batches
const batches = chunk(messages, BATCH_SIZE)
// Process each batch with worker pool
const processedCount = { success: 0, error: 0 }
const processedCount = { error: 0, success: 0 }
for (const batch of batches) {
await limit(async () => {
const embedPromises = batch.map(async (message) => {
try {
const embeddingRes = await embed({
baseURL: env.EMBEDDING_API_BASE_URL!,
apiKey: env.EMBEDDING_API_KEY!,
model: env.EMBEDDING_MODEL!,
baseURL: env.EMBEDDING_API_BASE_URL!,
input: message.content,
model: env.EMBEDDING_MODEL!,
})
switch (env.EMBEDDING_DIMENSION) {
case '1536':
case '768':
await db
.update(chatMessagesTable)
.set({ content_vector_1536: embeddingRes.embedding })
.set({ content_vector_768: embeddingRes.embedding })
.where(eq(chatMessagesTable.id, message.id))
break
case '1024':
@@ -79,10 +79,10 @@ async function main() {
.set({ content_vector_1024: embeddingRes.embedding })
.where(eq(chatMessagesTable.id, message.id))
break
case '768':
case '1536':
await db
.update(chatMessagesTable)
.set({ content_vector_768: embeddingRes.embedding })
.set({ content_vector_1536: embeddingRes.embedding })
.where(eq(chatMessagesTable.id, message.id))
break
default:
@@ -21,8 +21,8 @@ export async function readMessage(
unreadMessages: Message[],
abortController: AbortController,
): Promise<{
loop?: boolean
break?: boolean
loop?: boolean
result: string
}> {
const logger = useLogg('readMessage').useGlobalConfig()
@@ -50,11 +50,11 @@ export async function readMessage(
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,
apiKey: env.EMBEDDING_API_KEY!,
baseURL: env.EMBEDDING_API_BASE_URL!,
input: msg.text || msg.caption || '',
model: env.EMBEDDING_MODEL!,
})
span.end()
@@ -93,8 +93,8 @@ export async function readMessage(
break: true,
result: await actionReadMessages({
lastMessages: lastNMessagesOneliner,
unreadHistoryMessages: unreadHistoryMessageOneliner,
relevantChatMessages: relevantChatMessagesOneliner,
unreadHistoryMessages: unreadHistoryMessageOneliner,
}),
}
})
@@ -25,7 +25,7 @@ export function parseMayStructuredMessage(responseText: string) {
if (result) {
logger.withField('text', JSON.stringify(responseText)).withField('result', result).log('Multiple messages detected')
const parsedResponse = parse(result[0]) as ({ messages?: unknown, reply_to_message_id?: unknown } | undefined)
const parsedResponse = parse(result[0]) as (undefined | { messages?: unknown, reply_to_message_id?: unknown })
const hasMessagesArray = Array.isArray(parsedResponse?.messages)
const messages = Array.isArray(parsedResponse?.messages)
? parsedResponse.messages.filter((message): message is string => typeof message === 'string' && message.trim() !== '')
@@ -79,15 +79,15 @@ export async function sendMessage(
const systemContent = String(await messageSplit())
const req = {
abortSignal: abortController.signal,
apiKey: env.LLM_API_KEY!,
baseURL: env.LLM_API_BASE_URL!,
model: env.LLM_MODEL!,
messages: message.messages(
{ role: 'system' as const, content: systemContent },
{ role: 'user' as const, content: 'This is the input message:' },
{ role: 'user' as const, content: String(responseText) },
{ content: systemContent, role: 'system' as const },
{ content: 'This is the input message:', role: 'user' as const },
{ content: String(responseText), role: 'user' as const },
),
abortSignal: abortController.signal,
model: env.LLM_MODEL!,
} satisfies GenerateTextOptions
if (env.LLM_OLLAMA_DISABLE_THINK) {
(req as Record<string, unknown>).think = false
@@ -106,12 +106,12 @@ export async function sendMessage(
}
logger.withFields({
messages: responseText,
response: res.text,
now: new Date().toLocaleString(),
totalTokens: res.usage.totalTokens,
promptTokens: res.usage.inputTokens,
completion_tokens: res.usage.outputTokens,
messages: responseText,
now: new Date().toLocaleString(),
promptTokens: res.usage.inputTokens,
response: res.text,
totalTokens: res.usage.totalTokens,
}).log('Message split')
const structuredMessage = parseMayStructuredMessage(res.text)
@@ -8,9 +8,9 @@ export function createAttentionHandler(bot: BotContext, config: AttentionConfig)
currentResponseRate: config.initialResponseRate,
lastResponseTimes: new Map(),
stats: {
lastInteractionTime: Date.now(),
mentionCount: 0,
triggerWordCount: 0,
lastInteractionTime: Date.now(),
},
}
@@ -47,7 +47,7 @@ export function createAttentionHandler(bot: BotContext, config: AttentionConfig)
return now - lastResponse >= config.cooldownMs
}
const checkTriggerWords = (text?: string): string | false => {
const checkTriggerWords = (text?: string): false | string => {
if (!text || !config.triggerWords.length)
return false
return config.triggerWords.find(word => text.includes(word)) || false
@@ -66,65 +66,6 @@ export function createAttentionHandler(bot: BotContext, config: AttentionConfig)
// Public interface
const handler = {
async shouldRespond(chatId: string, messages: Message[]): Promise<{ shouldAct: boolean, reason: string, responseRate?: number }> {
const fromPrivate = messages.every(message => message.chat.type === 'private')
const mentioned = messages.some(message => message.text?.includes(`@${bot.bot.botInfo.username}`))
const reply = messages.some(message => message.reply_to_message?.from?.id.toString() === bot.bot.botInfo.id.toString())
try {
// Always respond to private messages
if (fromPrivate) {
state.stats.mentionCount++
state.stats.lastInteractionTime = Date.now()
return { shouldAct: true, reason: 'private_message' }
}
if (mentioned || reply) {
state.stats.mentionCount++
state.stats.lastInteractionTime = Date.now()
return { shouldAct: true, reason: 'mention_or_reply' }
}
// Check trigger words
const matchedTrigger = checkTriggerWords(messages.map(message => message.text).join(' '))
if (matchedTrigger) {
state.stats.triggerWordCount++
state.stats.lastInteractionTime = Date.now()
return { shouldAct: true, reason: `trigger_word:${matchedTrigger}` }
}
// Check cooldown
if (!checkCooldown(chatId)) {
return { shouldAct: false, reason: 'cooldown' }
}
// Check ignore words
if (checkIgnoreWords(messages.map(message => message.text).join(' '))) {
return { shouldAct: false, reason: 'ignore_word' }
}
// Random response based on current rate
if (Math.random() < state.currentResponseRate) {
state.lastResponseTimes.set(chatId, Date.now())
return {
shouldAct: true,
reason: 'random',
responseRate: state.currentResponseRate,
}
}
return {
shouldAct: false,
reason: 'rate_check_failed',
responseRate: state.currentResponseRate,
}
}
catch (error) {
bot.logger.withError(error).log('Error in attention handler')
return { shouldAct: false, reason: 'error' }
}
},
// Cleanup function
destroy() {
clearInterval(decayInterval)
@@ -134,6 +75,65 @@ export function createAttentionHandler(bot: BotContext, config: AttentionConfig)
getState() {
return { ...state }
},
async shouldRespond(chatId: string, messages: Message[]): Promise<{ reason: string, responseRate?: number, shouldAct: boolean }> {
const fromPrivate = messages.every(message => message.chat.type === 'private')
const mentioned = messages.some(message => message.text?.includes(`@${bot.bot.botInfo.username}`))
const reply = messages.some(message => message.reply_to_message?.from?.id.toString() === bot.bot.botInfo.id.toString())
try {
// Always respond to private messages
if (fromPrivate) {
state.stats.mentionCount++
state.stats.lastInteractionTime = Date.now()
return { reason: 'private_message', shouldAct: true }
}
if (mentioned || reply) {
state.stats.mentionCount++
state.stats.lastInteractionTime = Date.now()
return { reason: 'mention_or_reply', shouldAct: true }
}
// Check trigger words
const matchedTrigger = checkTriggerWords(messages.map(message => message.text).join(' '))
if (matchedTrigger) {
state.stats.triggerWordCount++
state.stats.lastInteractionTime = Date.now()
return { reason: `trigger_word:${matchedTrigger}`, shouldAct: true }
}
// Check cooldown
if (!checkCooldown(chatId)) {
return { reason: 'cooldown', shouldAct: false }
}
// Check ignore words
if (checkIgnoreWords(messages.map(message => message.text).join(' '))) {
return { reason: 'ignore_word', shouldAct: false }
}
// Random response based on current rate
if (Math.random() < state.currentResponseRate) {
state.lastResponseTimes.set(chatId, Date.now())
return {
reason: 'random',
responseRate: state.currentResponseRate,
shouldAct: true,
}
}
return {
reason: 'rate_check_failed',
responseRate: state.currentResponseRate,
shouldAct: false,
}
}
catch (error) {
bot.logger.withError(error).log('Error in attention handler')
return { reason: 'error', shouldAct: false }
}
},
}
return handler
@@ -2,11 +2,11 @@ import type { Message as LLMMessage } from '@xsai/shared-chat'
import type { Message } from 'grammy/types'
interface InterruptionParams {
processingTime: number
messageCount: number
currentMessages: LLMMessage[]
newMessages: Message[]
chatId: string
currentMessages: LLMMessage[]
messageCount: number
newMessages: Message[]
processingTime: number
}
export async function shouldInterruptProcessing(params: InterruptionParams): Promise<boolean> {
@@ -20,439 +20,6 @@ import { readMessage } from './agent/actions/read-message'
import { sendMessage } from './agent/actions/send-message'
// import { shouldInterruptProcessing } from './agent/interruption'
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
}
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
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.`))
}
}
}
async function handleLoopStep(ctx: BotContext, chatCtx: ChatContext, incomingMessage?: Message): Promise<() => Promise<any> | undefined> {
ctx.currentProcessingStartTime = Date.now()
if (chatCtx?.currentAbortController) {
chatCtx.currentAbortController.abort()
}
// // Create a new abort controller for this loop execution
// const unreadMessagesForThisChat = ctx.unreadMessages[chatCtx.chatId]
// 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)
}
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.`))
}
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 chatCtx.actions[number] }[] = []
if (chatCtx) {
for (const actionHistory of chatCtx.actions) {
if (actionHistory.action.action === 'read_unread_messages') {
readUnreadMessagesActions.push({ index: chatCtx.actions.indexOf(actionHistory), actionState: actionHistory })
}
}
}
readUnreadMessagesActions.map((item, index) => {
if (index === readUnreadMessagesActions.length - 1) {
return item
}
item.actionState.result = 'AIRI System: Please refer to the last read_unread_messages action for context.'
return item
})
if (chatCtx) {
for (const item of readUnreadMessagesActions) {
chatCtx.actions[item.index] = item.actionState
}
}
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') {
ctx.logger.log('Operation was aborted due to interruption')
return
}
ctx.logger.withError(err).log('Error occurred')
}
finally {
// Clean up timing when done
if (chatCtx && chatCtx.currentAbortController === currentController) {
chatCtx.currentAbortController = undefined
ctx.currentProcessingStartTime = undefined
}
}
}
async function isChatIdBotAdmin(fromId: number) {
if (!env.ADMIN_USER_IDS) {
return false
}
const admins = env.ADMIN_USER_IDS.split(',')
if (admins.length === 0) {
return false
}
return admins.includes(fromId.toString())
}
async function loopIterationForChat(bot: BotContext, chatContext: ChatContext, incomingMessage: Message) {
let result = await handleLoopStep(bot, chatContext, incomingMessage)
while (typeof result === 'function') {
result = await result()
}
return result
}
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 createBotContext(telegramBot: Bot, logger: Logg): BotContext {
const botSelf: BotContext = {
bot: telegramBot,
messageQueue: [],
unreadMessages: {},
processedIds: new Set(),
logger,
processing: false,
lastInteractedNChatIds: [],
chats: new Map<string, ChatContext>(),
}
return botSelf
}
async function onMessageArrival(botContext: BotContext, chatCtx: ChatContext) {
if (botContext.processing)
return
botContext.processing = true
try {
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(botContext.bot, nextMsg.message, nextMsg.message.sticker)
nextMsg.status = 'ready'
}
else if (nextMsg.message.photo) {
nextMsg.status = 'interpreting'
await interpretPhotos(botContext, nextMsg.message, nextMsg.message.photo)
nextMsg.status = 'ready'
}
else {
nextMsg.status = 'ready'
}
}
if (nextMsg.status === 'ready') {
switch (nextMsg.message.chat.type) {
case 'private':
await recordJoinedChat(nextMsg.message.chat.id.toString(), `${nextMsg.message.from.first_name} ${nextMsg.message.from.last_name}`)
break
case 'channel':
case 'group':
case 'supergroup':
await recordJoinedChat(nextMsg.message.chat.id.toString(), nextMsg.message.chat.title)
break
}
await recordMessage(botContext.bot.botInfo, nextMsg.message)
let unreadMessagesForThisChat = botContext.unreadMessages[nextMsg.message.chat.id]
if (unreadMessagesForThisChat == null) {
botContext.logger.withField('chatId', nextMsg.message.chat.id).log('unread messages for this chat is null - creating empty array')
unreadMessagesForThisChat = []
}
if (!Array.isArray(unreadMessagesForThisChat)) {
botContext.logger.withField('chatId', nextMsg.message.chat.id).log('unread messages for this chat is not an array - converting to array')
unreadMessagesForThisChat = []
}
unreadMessagesForThisChat.push(nextMsg.message)
if (unreadMessagesForThisChat.length > 100) {
unreadMessagesForThisChat = unreadMessagesForThisChat.slice(-100)
}
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
loopIterationForChat(botContext, chatCtx, nextMsg.message)
botContext.messageQueue.shift()
}
}
}
catch (err) {
botContext.logger.withError(err).log('Error occurred')
}
finally {
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()
@@ -541,3 +108,436 @@ export async function startTelegramBot() {
console.error(err)
}
}
function createBotContext(telegramBot: Bot, logger: Logg): BotContext {
const botSelf: BotContext = {
bot: telegramBot,
chats: new Map<string, ChatContext>(),
lastInteractedNChatIds: [],
logger,
messageQueue: [],
processedIds: new Set(),
processing: false,
unreadMessages: {},
}
return botSelf
}
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 '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 'continue':
if (chatCtx) {
chatCtx.actions.push({ action, result: 'AIRI System: Acknowledged, will now continue until next tick.' })
}
return
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
}
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
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 '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 '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.`))
}
}
}
function ensureChatContext(botCtx: BotContext, chatId: string): ChatContext {
if (botCtx.chats.has(chatId)) {
return botCtx.chats.get(chatId)!
}
const newChatContext: ChatContext = {
actions: [],
chatId,
currentAbortController: undefined,
currentTask: undefined,
messages: [],
}
botCtx.chats.set(chatId, newChatContext)
return newChatContext
}
async function handleLoopStep(ctx: BotContext, chatCtx: ChatContext, incomingMessage?: Message): Promise<() => Promise<any> | undefined> {
ctx.currentProcessingStartTime = Date.now()
if (chatCtx?.currentAbortController) {
chatCtx.currentAbortController.abort()
}
// // Create a new abort controller for this loop execution
// const unreadMessagesForThisChat = ctx.unreadMessages[chatCtx.chatId]
// 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)
}
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.`))
}
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: { actionState: typeof chatCtx.actions[number], index: number }[] = []
if (chatCtx) {
for (const actionHistory of chatCtx.actions) {
if (actionHistory.action.action === 'read_unread_messages') {
readUnreadMessagesActions.push({ actionState: actionHistory, index: chatCtx.actions.indexOf(actionHistory) })
}
}
}
readUnreadMessagesActions.map((item, index) => {
if (index === readUnreadMessagesActions.length - 1) {
return item
}
item.actionState.result = 'AIRI System: Please refer to the last read_unread_messages action for context.'
return item
})
if (chatCtx) {
for (const item of readUnreadMessagesActions) {
chatCtx.actions[item.index] = item.actionState
}
}
const action = await imagineAnAction(ctx.bot.botInfo.id.toString(), currentController, chatCtx?.messages || [], chatCtx?.actions || [], { incomingMessages: [incomingMessage], unreadMessages: ctx.unreadMessages })
return await dispatchAction(ctx, action, currentController, chatCtx)
}
catch (err) {
if (err.name === 'AbortError') {
ctx.logger.log('Operation was aborted due to interruption')
return
}
ctx.logger.withError(err).log('Error occurred')
}
finally {
// Clean up timing when done
if (chatCtx && chatCtx.currentAbortController === currentController) {
chatCtx.currentAbortController = undefined
ctx.currentProcessingStartTime = undefined
}
}
}
async function isChatIdBotAdmin(fromId: number) {
if (!env.ADMIN_USER_IDS) {
return false
}
const admins = env.ADMIN_USER_IDS.split(',')
if (admins.length === 0) {
return false
}
return admins.includes(fromId.toString())
}
async function loopIterationForChat(bot: BotContext, chatContext: ChatContext, incomingMessage: Message) {
let result = await handleLoopStep(bot, chatContext, incomingMessage)
while (typeof result === 'function') {
result = await result()
}
return result
}
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)
}
async function onMessageArrival(botContext: BotContext, chatCtx: ChatContext) {
if (botContext.processing)
return
botContext.processing = true
try {
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(botContext.bot, nextMsg.message, nextMsg.message.sticker)
nextMsg.status = 'ready'
}
else if (nextMsg.message.photo) {
nextMsg.status = 'interpreting'
await interpretPhotos(botContext, nextMsg.message, nextMsg.message.photo)
nextMsg.status = 'ready'
}
else {
nextMsg.status = 'ready'
}
}
if (nextMsg.status === 'ready') {
switch (nextMsg.message.chat.type) {
case 'channel':
case 'group':
case 'supergroup':
await recordJoinedChat(nextMsg.message.chat.id.toString(), nextMsg.message.chat.title)
break
case 'private':
await recordJoinedChat(nextMsg.message.chat.id.toString(), `${nextMsg.message.from.first_name} ${nextMsg.message.from.last_name}`)
break
}
await recordMessage(botContext.bot.botInfo, nextMsg.message)
let unreadMessagesForThisChat = botContext.unreadMessages[nextMsg.message.chat.id]
if (unreadMessagesForThisChat == null) {
botContext.logger.withField('chatId', nextMsg.message.chat.id).log('unread messages for this chat is null - creating empty array')
unreadMessagesForThisChat = []
}
if (!Array.isArray(unreadMessagesForThisChat)) {
botContext.logger.withField('chatId', nextMsg.message.chat.id).log('unread messages for this chat is not an array - converting to array')
unreadMessagesForThisChat = []
}
unreadMessagesForThisChat.push(nextMsg.message)
if (unreadMessagesForThisChat.length > 100) {
unreadMessagesForThisChat = unreadMessagesForThisChat.slice(-100)
}
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
loopIterationForChat(botContext, chatCtx, nextMsg.message)
botContext.messageQueue.shift()
}
}
}
catch (err) {
botContext.logger.withError(err).log('Error occurred')
}
finally {
botContext.processing = false
}
}
+69 -69
View File
@@ -1,21 +1,21 @@
import { bigint, boolean, index, integer, jsonb, pgTable, text, uniqueIndex, uuid, vector } from 'drizzle-orm/pg-core'
export const chatMessagesTable = pgTable('chat_messages', {
id: uuid().primaryKey().defaultRandom(),
platform: text().notNull().default(''),
platform_message_id: text().notNull().default(''),
content: text().notNull().default(''),
content_vector_768: vector({ dimensions: 768 }),
content_vector_1024: vector({ dimensions: 1024 }),
content_vector_1536: vector({ dimensions: 1536 }),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
from_id: text().notNull().default(''),
from_name: text().notNull().default(''),
id: uuid().primaryKey().defaultRandom(),
in_chat_id: text().notNull().default(''),
content: text().notNull().default(''),
is_reply: boolean().notNull().default(false),
reply_to_name: text().notNull().default(''),
platform: text().notNull().default(''),
platform_message_id: text().notNull().default(''),
reply_to_id: text().notNull().default(''),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
reply_to_name: text().notNull().default(''),
updated_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
content_vector_1536: vector({ dimensions: 1536 }),
content_vector_1024: vector({ dimensions: 1024 }),
content_vector_768: vector({ dimensions: 768 }),
}, table => [
index('chat_messages_content_vector_1536_index').using('hnsw', table.content_vector_1536.op('vector_cosine_ops')),
index('chat_messages_content_vector_1024_index').using('hnsw', table.content_vector_1024.op('vector_cosine_ops')),
@@ -23,20 +23,20 @@ export const chatMessagesTable = pgTable('chat_messages', {
])
export const stickersTable = pgTable('stickers', {
id: uuid().primaryKey().defaultRandom(),
platform: text().notNull().default(''),
name: text().notNull().default(''),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
description: text().notNull().default(''),
description_vector_768: vector({ dimensions: 768 }),
description_vector_1024: vector({ dimensions: 1024 }),
description_vector_1536: vector({ dimensions: 1536 }),
emoji: text().notNull().default(''),
label: text().notNull().default(''),
file_id: text().notNull().default(''),
id: uuid().primaryKey().defaultRandom(),
image_base64: text().notNull().default(''),
image_path: text().notNull().default(''),
description: text().notNull().default(''),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
label: text().notNull().default(''),
name: text().notNull().default(''),
platform: text().notNull().default(''),
updated_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
description_vector_1536: vector({ dimensions: 1536 }),
description_vector_1024: vector({ dimensions: 1024 }),
description_vector_768: vector({ dimensions: 768 }),
}, table => [
index('stickers_description_vector_1536_index').using('hnsw', table.description_vector_1536.op('vector_cosine_ops')),
index('stickers_description_vector_1024_index').using('hnsw', table.description_vector_1024.op('vector_cosine_ops')),
@@ -44,37 +44,37 @@ export const stickersTable = pgTable('stickers', {
])
export const stickerPacksTable = pgTable('sticker_packs', {
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
description: text().notNull().default(''),
id: uuid().primaryKey().defaultRandom(),
name: text().notNull().default(''),
platform: text().notNull().default(''),
platform_id: text().notNull().default(''),
name: text().notNull().default(''),
description: text().notNull().default(''),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
updated_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
}, table => [
uniqueIndex('sticker_packs_platform_platform_id_unique_index').on(table.platform, table.platform_id),
])
export const recentSentStickersTable = pgTable('recent_sent_stickers', {
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
id: uuid().primaryKey().defaultRandom(),
sticker_id: uuid().notNull().references(() => stickersTable.id, { onDelete: 'cascade' }),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
updated_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
})
export const photosTable = pgTable('photos', {
id: uuid().primaryKey().defaultRandom(),
platform: text().notNull().default(''),
caption: text().notNull().default(''),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
description: text().notNull().default(''),
description_vector_768: vector({ dimensions: 768 }),
description_vector_1024: vector({ dimensions: 1024 }),
description_vector_1536: vector({ dimensions: 1536 }),
file_id: text().notNull().default(''),
id: uuid().primaryKey().defaultRandom(),
image_base64: text().notNull().default(''),
image_path: text().notNull().default(''),
caption: text().notNull().default(''),
description: text().notNull().default(''),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
platform: text().notNull().default(''),
updated_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
description_vector_1536: vector({ dimensions: 1536 }),
description_vector_1024: vector({ dimensions: 1024 }),
description_vector_768: vector({ dimensions: 768 }),
}, table => [
index('photos_description_vector_1536_index').using('hnsw', table.description_vector_1536.op('vector_cosine_ops')),
index('photos_description_vector_1024_index').using('hnsw', table.description_vector_1024.op('vector_cosine_ops')),
@@ -83,11 +83,11 @@ export const photosTable = pgTable('photos', {
export const joinedChatsTable = pgTable('joined_chats', () => {
return {
id: uuid().primaryKey().defaultRandom(),
platform: text().notNull().default(''),
chat_id: text().notNull().default('').unique(),
chat_name: text().notNull().default(''),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
id: uuid().primaryKey().defaultRandom(),
platform: text().notNull().default(''),
updated_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
}
}, (table) => {
@@ -99,29 +99,29 @@ export const joinedChatsTable = pgTable('joined_chats', () => {
})
export const chatCompletionsHistoryTable = pgTable('chat_completions_history', {
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
id: uuid().primaryKey().defaultRandom(),
prompt: text().notNull(),
response: text().notNull(),
task: text().notNull(),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
})
// Memory Item table - base table for all memories
export const memoryFragmentsTable = pgTable('memory_fragments', {
id: uuid().primaryKey().defaultRandom(),
content: text().notNull(),
memory_type: text().notNull(), // 'working', 'short_term', 'long_term', 'muscle'
category: text().notNull(), // 'chat', 'relationships', 'people', 'life', etc.
importance: integer().notNull().default(5), // 1-10 scale
emotional_impact: integer().notNull().default(0), // -10 to 10 scale
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
last_accessed: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
access_count: integer().notNull().default(1),
metadata: jsonb().notNull().default({}),
content_vector_1536: vector({ dimensions: 1536 }),
content_vector_1024: vector({ dimensions: 1024 }),
category: text().notNull(), // 'chat', 'relationships', 'people', 'life', etc.
content: text().notNull(),
content_vector_768: vector({ dimensions: 768 }),
content_vector_1024: vector({ dimensions: 1024 }),
content_vector_1536: vector({ dimensions: 1536 }),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
deleted_at: bigint({ mode: 'number' }), // nullable timestamp for soft delete
emotional_impact: integer().notNull().default(0), // -10 to 10 scale
id: uuid().primaryKey().defaultRandom(),
importance: integer().notNull().default(5), // 1-10 scale
last_accessed: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
memory_type: text().notNull(), // 'working', 'short_term', 'long_term', 'muscle'
metadata: jsonb().notNull().default({}),
}, table => [
// Vector indexes for efficient similarity search
index('memory_items_content_vector_1536_index').using('hnsw', table.content_vector_1536.op('vector_cosine_ops')),
@@ -137,11 +137,11 @@ export const memoryFragmentsTable = pgTable('memory_fragments', {
// Memory Tags junction table
export const memoryTagsTable = pgTable('memory_tags', {
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
deleted_at: bigint({ mode: 'number' }), // nullable timestamp for soft delete
id: uuid().primaryKey().defaultRandom(),
memory_id: uuid().notNull().references(() => memoryFragmentsTable.id, { onDelete: 'cascade' }),
tag: text().notNull(),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
deleted_at: bigint({ mode: 'number' }), // nullable timestamp for soft delete
}, table => [
index('memory_tags_memory_id_index').on(table.memory_id),
index('memory_tags_tag_index').on(table.tag),
@@ -149,13 +149,13 @@ export const memoryTagsTable = pgTable('memory_tags', {
// Episodic Memory (specific events)
export const memoryEpisodicTable = pgTable('memory_episodic', {
id: uuid().primaryKey().defaultRandom(),
memory_id: uuid().notNull().references(() => memoryFragmentsTable.id, { onDelete: 'cascade' }),
event_type: text().notNull(), // 'conversation', 'introduction', 'argument', etc.
participants: jsonb().notNull().default([]), // Array of participant IDs
location: text().default(''),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
deleted_at: bigint({ mode: 'number' }), // nullable timestamp for soft delete
event_type: text().notNull(), // 'conversation', 'introduction', 'argument', etc.
id: uuid().primaryKey().defaultRandom(),
location: text().default(''),
memory_id: uuid().notNull().references(() => memoryFragmentsTable.id, { onDelete: 'cascade' }),
participants: jsonb().notNull().default([]), // Array of participant IDs
}, table => [
index('memory_episodic_memory_id_index').on(table.memory_id),
index('memory_episodic_event_type_index').on(table.event_type),
@@ -163,18 +163,18 @@ export const memoryEpisodicTable = pgTable('memory_episodic', {
// Goals table
export const memoryLongTermGoalsTable = pgTable('memory_long_term_goals', {
id: uuid().primaryKey().defaultRandom(),
title: text().notNull(),
description: text().notNull(),
priority: integer().notNull().default(5), // 1-10 scale
progress: integer().notNull().default(0), // 0-100 percentage
deadline: bigint({ mode: 'number' }).default(null),
status: text().notNull().default('planned'), // 'planned', 'in_progress', 'completed', 'abandoned'
parent_goal_id: uuid().references(() => memoryLongTermGoalsTable.id),
category: text().notNull().default('personal'),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
updated_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
deadline: bigint({ mode: 'number' }).default(null),
deleted_at: bigint({ mode: 'number' }), // nullable timestamp for soft delete
description: text().notNull(),
id: uuid().primaryKey().defaultRandom(),
parent_goal_id: uuid().references(() => memoryLongTermGoalsTable.id),
priority: integer().notNull().default(5), // 1-10 scale
progress: integer().notNull().default(0), // 0-100 percentage
status: text().notNull().default('planned'), // 'planned', 'in_progress', 'completed', 'abandoned'
title: text().notNull(),
updated_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
}, table => [
index('memory_long_term_goals_priority_index').on(table.priority),
index('memory_long_term_goals_status_index').on(table.status),
@@ -184,18 +184,18 @@ export const memoryLongTermGoalsTable = pgTable('memory_long_term_goals', {
// Ideas generated from dreams or normal thinking
export const memoryShortTermIdeas = pgTable('memory_short_term_ideas', {
id: uuid().primaryKey().defaultRandom(),
content: text().notNull(),
source_type: text().notNull().default('dream'), // 'dream', 'conversation', 'reflection'
source_id: text().default(null), // ID of source (dream ID, conversation ID, etc.)
status: text().notNull().default('new'), // 'new', 'developing', 'implemented', 'abandoned'
excitement: integer().notNull().default(5), // 1-10 scale
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
updated_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
content_vector_1536: vector({ dimensions: 1536 }),
content_vector_1024: vector({ dimensions: 1024 }),
content_vector_768: vector({ dimensions: 768 }),
content_vector_1024: vector({ dimensions: 1024 }),
content_vector_1536: vector({ dimensions: 1536 }),
created_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
deleted_at: bigint({ mode: 'number' }), // nullable timestamp for soft delete
excitement: integer().notNull().default(5), // 1-10 scale
id: uuid().primaryKey().defaultRandom(),
source_id: text().default(null), // ID of source (dream ID, conversation ID, etc.)
source_type: text().notNull().default('dream'), // 'dream', 'conversation', 'reflection'
status: text().notNull().default('new'), // 'new', 'developing', 'implemented', 'abandoned'
updated_at: bigint({ mode: 'number' }).notNull().default(0).$defaultFn(() => Date.now()),
}, table => [
index('memory_short_term_ideas_source_type_index').on(table.source_type),
index('memory_short_term_ideas_status_index').on(table.status),
+6 -6
View File
@@ -16,6 +16,12 @@ setGlobalLogLevel(LogLevel.Debug)
async function main() {
const sdk = new NodeSDK({
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT || 'http://localhost:4318/v1/metrics',
}),
exportIntervalMillis: 5000,
}),
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: 'moeru_ai.airi.telegram_bot',
[ATTR_SERVICE_VERSION]: '1.0.0',
@@ -23,12 +29,6 @@ async function main() {
traceExporter: new OTLPTraceExporter({
url: env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || 'http://localhost:4318/v1/traces',
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT || 'http://localhost:4318/v1/metrics',
}),
exportIntervalMillis: 5000,
}),
})
sdk.start()
+14 -14
View File
@@ -21,8 +21,8 @@ export async function imagineAnAction(
messages: LLMMessage[],
actions: { action: Action, result: unknown }[],
globalStates: {
unreadMessages: Record<string, Message[]>
incomingMessages?: Message[]
unreadMessages: Record<string, Message[]>
},
): Promise<Action | undefined> {
const logger = useLogg('imagineAnAction').useGlobalConfig()
@@ -58,9 +58,9 @@ export async function imagineAnAction(
'Respond with the action and parameters you choose in JSON only, without any explanation and markups.',
))
const requestMessages = message.messages(
{ role: 'system' as const, content: systemContent },
{ content: systemContent, role: 'system' as const },
...messages,
{ role: 'user' as const, content: userContent },
{ content: userContent, role: 'user' as const },
)
try {
@@ -70,11 +70,11 @@ export async function imagineAnAction(
s.setAttribute('llm.provider.api_base_url', env.LLM_API_BASE_URL!)
const req = {
abortSignal: currentAbortController?.signal,
apiKey: env.LLM_API_KEY!,
baseURL: env.LLM_API_BASE_URL!,
model: env.LLM_MODEL!,
messages: requestMessages,
abortSignal: currentAbortController?.signal,
model: env.LLM_MODEL!,
} satisfies GenerateTextOptions
if (env.LLM_OLLAMA_DISABLE_THINK) {
(req as Record<string, unknown>).think = false
@@ -96,12 +96,12 @@ export async function imagineAnAction(
})
logger.withFields({
response: res.text,
unreadMessages: Object.fromEntries(Object.entries(globalStates.unreadMessages).map(([key, value]) => [key, value.length])),
now: new Date().toLocaleString(),
totalTokens: res.usage.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,
unreadMessages: Object.fromEntries(Object.entries(globalStates.unreadMessages).map(([key, value]) => [key, value.length])),
}).log('Generated action')
const action = tracer.startActiveSpan('telegram.module.generate_agent_action.parse', (s) => {
@@ -126,13 +126,13 @@ export async function imagineAnAction(
// Normalize action name aliases
const actionAliases: Record<string, string> = {
read_messages: 'read_unread_messages',
get_unread_messages: 'read_unread_messages',
check_messages: 'read_unread_messages',
reply_to_a_message_from_a_chat: 'send_message',
reply_message: 'send_message',
get_messages_from_chat: 'read_unread_messages',
get_unread_messages: 'read_unread_messages',
read_messages: 'read_unread_messages',
Read_unread_messages: 'read_unread_messages',
reply_message: 'send_message',
reply_to_a_message_from_a_chat: 'send_message',
}
if (typeof raw.action === 'string' && actionAliases[raw.action])
raw.action = actionAliases[raw.action]
@@ -23,30 +23,6 @@ import { toPngBase64FromFile } from './image'
// Set path to FFmpeg binaries
ffmpeg.setFfmpegPath(ffmpegInstaller.path)
async function extractFrames(inputFilePath, outputDir, frameRate = 5) {
await fs.mkdir(outputDir, { recursive: true })
const outputPattern = path.join(outputDir, 'frame-%03d.png')
return new Promise<string[]>((resolve, reject) => {
ffmpeg(inputFilePath)
.outputOptions(`-vf fps=${frameRate}`)
.output(outputPattern)
.on('end', async () => {
const files = await fs.readdir(outputDir)
const sortedFiles = files
.filter(file => file.match(/frame-\d+\.png/))
.sort((a, b) => {
const numA = Number.parseInt(a.match(/frame-(\d+)\.png/)[1])
const numB = Number.parseInt(b.match(/frame-(\d+)\.png/)[1])
return numA - numB
})
resolve(sortedFiles.map(file => path.join(outputDir, file)))
})
.on('error', err => reject(err))
.run()
})
}
export async function interpretAnimatedSticker(bot: Bot, msg: Message, sticker: Sticker) {
const logger = useLogg('interpretAnimatedSticker')
.useGlobalConfig()
@@ -89,8 +65,8 @@ export async function interpretAnimatedSticker(bot: Bot, msg: Message, sticker:
// Process frames with Sharp
const frames = await Promise.all(sampled.map(async (framePath, index) => ({
index,
base64: await toPngBase64FromFile(framePath),
index,
})))
logger.withField('sampled_frames', sampled).log('Normalized the frames')
@@ -101,7 +77,6 @@ export async function interpretAnimatedSticker(bot: Bot, msg: Message, sticker:
const req = {
apiKey: env.LLM_VISION_API_KEY!,
baseURL: env.LLM_VISION_API_BASE_URL!,
model: env.LLM_VISION_MODEL!,
messages: message.messages(
message.system(div(
span(`
@@ -124,6 +99,7 @@ export async function interpretAnimatedSticker(bot: Bot, msg: Message, sticker:
)),
message.user([message.imagePart(`data:image/png;base64,${frame.base64}`)]),
),
model: env.LLM_VISION_MODEL!,
} satisfies GenerateTextOptions
if (env.LLM_OLLAMA_DISABLE_THINK) {
(req as Record<string, unknown>).think = false
@@ -136,8 +112,8 @@ export async function interpretAnimatedSticker(bot: Bot, msg: Message, sticker:
}
frameDescriptions.push({
frameNumber: frame.index + 1,
description: res.text,
frameNumber: frame.index + 1,
})
}
catch (err) {
@@ -159,7 +135,6 @@ export async function interpretAnimatedSticker(bot: Bot, msg: Message, sticker:
const req = {
apiKey: env.LLM_API_KEY!, // Using text-only LLM API
baseURL: env.LLM_API_BASE_URL!,
model: env.LLM_MODEL!,
messages: message.messages(
message.system(
div(
@@ -191,6 +166,7 @@ export async function interpretAnimatedSticker(bot: Bot, msg: Message, sticker:
),
),
),
model: env.LLM_MODEL!,
} satisfies GenerateTextOptions
if (env.LLM_OLLAMA_DISABLE_THINK) {
(req as Record<string, unknown>).think = false
@@ -199,7 +175,7 @@ export async function interpretAnimatedSticker(bot: Bot, msg: Message, sticker:
const consolidatedResult = await generateText(req)
// Clean up temp files
await fs.rm(tempDir, { recursive: true, force: true })
await fs.rm(tempDir, { force: true, recursive: true })
logger.withField('consolidated_result', consolidatedResult.text).log('Animated sticker interpreted')
@@ -213,3 +189,27 @@ export async function interpretAnimatedSticker(bot: Bot, msg: Message, sticker:
logger.withError(err).log('Error interpreting animated sticker')
}
}
async function extractFrames(inputFilePath, outputDir, frameRate = 5) {
await fs.mkdir(outputDir, { recursive: true })
const outputPattern = path.join(outputDir, 'frame-%03d.png')
return new Promise<string[]>((resolve, reject) => {
ffmpeg(inputFilePath)
.outputOptions(`-vf fps=${frameRate}`)
.output(outputPattern)
.on('end', async () => {
const files = await fs.readdir(outputDir)
const sortedFiles = files
.filter(file => file.match(/frame-\d+\.png/))
.sort((a, b) => {
const numA = Number.parseInt(a.match(/frame-(\d+)\.png/)[1])
const numB = Number.parseInt(b.match(/frame-(\d+)\.png/)[1])
return numA - numB
})
resolve(sortedFiles.map(file => path.join(outputDir, file)))
})
.on('error', err => reject(err))
.run()
})
}
+4 -4
View File
@@ -5,10 +5,6 @@ import { ResizeFilterType, Transformer } from '@napi-rs/image'
type ImageInput = ArrayBuffer | Buffer | Uint8Array
function toUint8Array(input: ImageInput): Uint8Array {
return input instanceof Uint8Array ? input : new Uint8Array(input)
}
export async function toPngBase64(input: ImageInput) {
const transformer = new Transformer(toUint8Array(input))
transformer.resize(512, 512, ResizeFilterType.Lanczos3)
@@ -20,3 +16,7 @@ export async function toPngBase64FromFile(filePath: string) {
const buffer = await readFile(filePath)
return toPngBase64(buffer)
}
function toUint8Array(input: ImageInput): Uint8Array {
return input instanceof Uint8Array ? input : new Uint8Array(input)
}
+3 -3
View File
@@ -29,7 +29,6 @@ export async function interpretPhotos(state: BotContext, msg: Message, photos: P
const req = {
apiKey: env.LLM_VISION_API_KEY!,
baseURL: env.LLM_VISION_API_BASE_URL!,
model: env.LLM_VISION_MODEL!,
messages: message.messages(
message.system(''
+ 'You are a helpful assistant on visual content description work for blindness disability '
@@ -53,6 +52,7 @@ export async function interpretPhotos(state: BotContext, msg: Message, photos: P
),
message.user([message.imagePart(`data:image/png;base64,${base64}`)]),
),
model: env.LLM_VISION_MODEL!,
} satisfies GenerateTextOptions
if (env.LLM_OLLAMA_DISABLE_THINK) {
(req as Record<string, unknown>).think = false
@@ -66,10 +66,10 @@ export async function interpretPhotos(state: BotContext, msg: Message, photos: P
// TODO: implement this for photo searching
const _embedRes = await embed({
baseURL: env.EMBEDDING_API_BASE_URL!,
apiKey: env.EMBEDDING_API_KEY!,
model: env.EMBEDDING_MODEL!,
baseURL: env.EMBEDDING_API_BASE_URL!,
input: 'Hello, world!',
model: env.EMBEDDING_MODEL!,
})
await recordPhoto(base64, msg.photo[index].file_id, files[index].file_path, res.text)
+3 -3
View File
@@ -42,7 +42,6 @@ export async function interpretSticker(bot: Bot, msg: Message, sticker: Sticker)
const req = {
apiKey: env.LLM_VISION_API_KEY!,
baseURL: env.LLM_VISION_API_BASE_URL!,
model: env.LLM_VISION_MODEL!,
messages: message.messages(
message.system(div(
span(`
@@ -66,6 +65,7 @@ export async function interpretSticker(bot: Bot, msg: Message, sticker: Sticker)
)),
message.user([message.imagePart(`data:image/png;base64,${stickerBase64}`)]),
),
model: env.LLM_VISION_MODEL!,
} satisfies GenerateTextOptions
if (env.LLM_OLLAMA_DISABLE_THINK) {
(req as Record<string, unknown>).think = false
@@ -79,10 +79,10 @@ export async function interpretSticker(bot: Bot, msg: Message, sticker: Sticker)
// TODO: implement this for sticker searching
const _embedRes = await embed({
baseURL: env.EMBEDDING_API_BASE_URL!,
apiKey: env.EMBEDDING_API_KEY!,
model: env.EMBEDDING_MODEL!,
baseURL: env.EMBEDDING_API_BASE_URL!,
input: 'Hello, world!',
model: env.EMBEDDING_MODEL!,
})
await recordSticker(stickerBase64, sticker.file_id, file.file_path, res.text, sticker.set_name, sticker.emoji, sticker.set_name)
@@ -14,65 +14,6 @@ import { chatMessageToOneLine } from './common'
import { findPhotoDescription } from './photos'
import { findStickerDescription } from './stickers'
export async function recordMessage(botInfo: UserFromGetMe, message: Message) {
const replyToName = message.reply_to_message?.from.first_name || ''
let embedding: EmbedResult
let text: string
if (message.sticker != null) {
text = `A sticker sent by user ${await findStickerDescription(message.sticker.file_id)}, sticker set named ${message.sticker.set_name}`
}
else if (message.photo != null) {
text = `A set of photo, descriptions are: ${(await Promise.all(message.photo.map(photo => findPhotoDescription(photo.file_id)))).join('\n')}`
}
else if (message.text) {
text = message.text || message.caption || ''
}
if (text === '') {
return
}
else {
embedding = await embed({
baseURL: env.EMBEDDING_API_BASE_URL!,
apiKey: env.EMBEDDING_API_KEY!,
model: env.EMBEDDING_MODEL!,
input: text,
})
}
const values: Partial<Omit<typeof chatMessagesTable.$inferSelect, 'id' | 'created_at' | 'updated_at'>> = {
platform: 'telegram',
from_id: message.from.id.toString(),
platform_message_id: message.message_id.toString(),
from_name: message.from.first_name,
in_chat_id: message.chat.id.toString(),
content: text,
is_reply: !!message.reply_to_message,
reply_to_name: replyToName === botInfo.first_name ? 'Yourself' : replyToName,
reply_to_id: message.reply_to_message?.message_id.toString() || '',
}
switch (env.EMBEDDING_DIMENSION) {
case '1536':
values.content_vector_1536 = embedding.embedding
break
case '1024':
values.content_vector_1024 = embedding.embedding
break
case '768':
values.content_vector_768 = embedding.embedding
break
default:
throw new Error(`Unsupported embedding dimension: ${env.EMBEDDING_DIMENSION}`)
}
await useDrizzle()
.insert(chatMessagesTable)
.values(values)
}
export async function findLastNMessages(chatId: string, n: number) {
const res = await useDrizzle()
.select()
@@ -84,6 +25,21 @@ export async function findLastNMessages(chatId: string, n: number) {
return res.reverse()
}
export async function findMessagesByIDs(messageIds: string[]) {
const db = useDrizzle()
return await db
.select()
.from(chatMessagesTable)
.where(
and(
inArray(chatMessagesTable.platform_message_id, messageIds),
eq(chatMessagesTable.platform, 'telegram'),
ne(chatMessagesTable.platform_message_id, ''),
),
)
}
export async function findRelevantMessages(botId: string, chatId: string, unreadHistoryMessagesEmbedding: { embedding: number[] }[], excludeMessageIds: string[] = []) {
const db = useDrizzle()
const contextWindowSize = 5 // Number of messages to include before and after
@@ -95,14 +51,14 @@ export async function findRelevantMessages(botId: string, chatId: string, unread
let similarity: SQL<number>
switch (env.EMBEDDING_DIMENSION) {
case '1536':
similarity = sql<number>`(1 - (${cosineDistance(chatMessagesTable.content_vector_1536, embedding.embedding)}))`
case '768':
similarity = sql<number>`(1 - (${cosineDistance(chatMessagesTable.content_vector_768, embedding.embedding)}))`
break
case '1024':
similarity = sql<number>`(1 - (${cosineDistance(chatMessagesTable.content_vector_1024, embedding.embedding)}))`
break
case '768':
similarity = sql<number>`(1 - (${cosineDistance(chatMessagesTable.content_vector_768, embedding.embedding)}))`
case '1536':
similarity = sql<number>`(1 - (${cosineDistance(chatMessagesTable.content_vector_1536, embedding.embedding)}))`
break
default:
throw new Error(`Unsupported embedding dimension: ${env.EMBEDDING_DIMENSION}`)
@@ -114,21 +70,21 @@ export async function findRelevantMessages(botId: string, chatId: string, unread
// Get top messages with similarity above threshold
const relevantMessages = await db
.select({
id: chatMessagesTable.id,
platform: chatMessagesTable.platform,
platform_message_id: chatMessagesTable.platform_message_id,
combined_score: sql`${combinedScore} AS "combined_score"`,
content: chatMessagesTable.content,
created_at: chatMessagesTable.created_at,
from_id: chatMessagesTable.from_id,
from_name: chatMessagesTable.from_name,
id: chatMessagesTable.id,
in_chat_id: chatMessagesTable.in_chat_id,
content: chatMessagesTable.content,
is_reply: chatMessagesTable.is_reply,
reply_to_name: chatMessagesTable.reply_to_name,
platform: chatMessagesTable.platform,
platform_message_id: chatMessagesTable.platform_message_id,
reply_to_id: chatMessagesTable.reply_to_id,
created_at: chatMessagesTable.created_at,
updated_at: chatMessagesTable.updated_at,
reply_to_name: chatMessagesTable.reply_to_name,
similarity: sql`${similarity} AS "similarity"`,
time_relevance: sql`${timeRelevance} AS "time_relevance"`,
combined_score: sql`${combinedScore} AS "combined_score"`,
updated_at: chatMessagesTable.updated_at,
})
.from(chatMessagesTable)
.where(and(
@@ -148,17 +104,17 @@ export async function findRelevantMessages(botId: string, chatId: string, unread
// Get N messages before the target message
const messagesBefore = await db
.select({
id: chatMessagesTable.id,
platform: chatMessagesTable.platform,
platform_message_id: chatMessagesTable.platform_message_id,
content: chatMessagesTable.content,
created_at: chatMessagesTable.created_at,
from_id: chatMessagesTable.from_id,
from_name: chatMessagesTable.from_name,
id: chatMessagesTable.id,
in_chat_id: chatMessagesTable.in_chat_id,
content: chatMessagesTable.content,
is_reply: chatMessagesTable.is_reply,
reply_to_name: chatMessagesTable.reply_to_name,
platform: chatMessagesTable.platform,
platform_message_id: chatMessagesTable.platform_message_id,
reply_to_id: chatMessagesTable.reply_to_id,
created_at: chatMessagesTable.created_at,
reply_to_name: chatMessagesTable.reply_to_name,
updated_at: chatMessagesTable.updated_at,
})
.from(chatMessagesTable)
@@ -174,17 +130,17 @@ export async function findRelevantMessages(botId: string, chatId: string, unread
// Get N messages after the target message
const messagesAfter = await db
.select({
id: chatMessagesTable.id,
platform: chatMessagesTable.platform,
platform_message_id: chatMessagesTable.platform_message_id,
content: chatMessagesTable.content,
created_at: chatMessagesTable.created_at,
from_id: chatMessagesTable.from_id,
from_name: chatMessagesTable.from_name,
id: chatMessagesTable.id,
in_chat_id: chatMessagesTable.in_chat_id,
content: chatMessagesTable.content,
is_reply: chatMessagesTable.is_reply,
reply_to_name: chatMessagesTable.reply_to_name,
platform: chatMessagesTable.platform,
platform_message_id: chatMessagesTable.platform_message_id,
reply_to_id: chatMessagesTable.reply_to_id,
created_at: chatMessagesTable.created_at,
reply_to_name: chatMessagesTable.reply_to_name,
updated_at: chatMessagesTable.updated_at,
})
.from(chatMessagesTable)
@@ -244,17 +200,61 @@ export async function findRelevantMessages(botId: string, chatId: string, unread
}))
}
export async function findMessagesByIDs(messageIds: string[]) {
const db = useDrizzle()
export async function recordMessage(botInfo: UserFromGetMe, message: Message) {
const replyToName = message.reply_to_message?.from.first_name || ''
return await db
.select()
.from(chatMessagesTable)
.where(
and(
inArray(chatMessagesTable.platform_message_id, messageIds),
eq(chatMessagesTable.platform, 'telegram'),
ne(chatMessagesTable.platform_message_id, ''),
),
)
let embedding: EmbedResult
let text: string
if (message.sticker != null) {
text = `A sticker sent by user ${await findStickerDescription(message.sticker.file_id)}, sticker set named ${message.sticker.set_name}`
}
else if (message.photo != null) {
text = `A set of photo, descriptions are: ${(await Promise.all(message.photo.map(photo => findPhotoDescription(photo.file_id)))).join('\n')}`
}
else if (message.text) {
text = message.text || message.caption || ''
}
if (text === '') {
return
}
else {
embedding = await embed({
apiKey: env.EMBEDDING_API_KEY!,
baseURL: env.EMBEDDING_API_BASE_URL!,
input: text,
model: env.EMBEDDING_MODEL!,
})
}
const values: Partial<Omit<typeof chatMessagesTable.$inferSelect, 'created_at' | 'id' | 'updated_at'>> = {
content: text,
from_id: message.from.id.toString(),
from_name: message.from.first_name,
in_chat_id: message.chat.id.toString(),
is_reply: !!message.reply_to_message,
platform: 'telegram',
platform_message_id: message.message_id.toString(),
reply_to_id: message.reply_to_message?.message_id.toString() || '',
reply_to_name: replyToName === botInfo.first_name ? 'Yourself' : replyToName,
}
switch (env.EMBEDDING_DIMENSION) {
case '768':
values.content_vector_768 = embedding.embedding
break
case '1024':
values.content_vector_1024 = embedding.embedding
break
case '1536':
values.content_vector_1536 = embedding.embedding
break
default:
throw new Error(`Unsupported embedding dimension: ${env.EMBEDDING_DIMENSION}`)
}
await useDrizzle()
.insert(chatMessagesTable)
.values(values)
}
@@ -15,15 +15,15 @@ export async function recordJoinedChat(chatId: string, chatName: string) {
return useDrizzle()
.insert(joinedChatsTable)
.values({
platform: 'telegram',
chat_id: chatId,
chat_name: chatName,
platform: 'telegram',
})
.onConflictDoUpdate({
target: joinedChatsTable.chat_id,
set: {
chat_name: chatName,
updated_at: Date.now(),
},
target: joinedChatsTable.chat_id,
})
}
@@ -5,7 +5,7 @@ import type { chatMessagesTable } from '../db/schema'
import { findPhotoDescription } from './photos'
import { findStickerDescription } from './stickers'
export function chatMessageToOneLine(botId: string, message: Omit<typeof chatMessagesTable.$inferSelect, 'content_vector_1536' | 'content_vector_768' | 'content_vector_1024'>, repliedToMessage?: Omit<typeof chatMessagesTable.$inferSelect, 'content_vector_1536' | 'content_vector_768' | 'content_vector_1024'>) {
export function chatMessageToOneLine(botId: string, message: Omit<typeof chatMessagesTable.$inferSelect, 'content_vector_768' | 'content_vector_1024' | 'content_vector_1536'>, repliedToMessage?: Omit<typeof chatMessagesTable.$inferSelect, 'content_vector_768' | 'content_vector_1024' | 'content_vector_1536'>) {
let userDisplayName = `User [${message.from_name}]`
if (botId === message.from_id) {
+12 -12
View File
@@ -17,21 +17,21 @@ export async function findPhotoDescription(fileId: string) {
return photo[0].description
}
export async function recordPhoto(photoBase64: string, fileId: string, filePath: string, description: string) {
await useDrizzle()
.insert(photosTable)
.values({
platform: 'telegram',
file_id: fileId,
image_base64: photoBase64,
image_path: filePath,
description,
})
}
export async function findPhotosDescriptions(fileIds: string[]) {
return await useDrizzle()
.select()
.from(photosTable)
.where(inArray(photosTable.file_id, fileIds))
}
export async function recordPhoto(photoBase64: string, fileId: string, filePath: string, description: string) {
await useDrizzle()
.insert(photosTable)
.values({
description,
file_id: fileId,
image_base64: photoBase64,
image_path: filePath,
platform: 'telegram',
})
}
@@ -3,20 +3,20 @@ import { desc } from 'drizzle-orm'
import { useDrizzle } from '../db'
import { stickerPacksTable } from '../db/schema'
export async function recordStickerPack(platformId: string, name: string, platform = 'telegram') {
await useDrizzle()
.insert(stickerPacksTable)
.values({
platform,
platform_id: platformId,
name,
description: '',
})
}
export async function listStickerPacks() {
return await useDrizzle()
.select()
.from(stickerPacksTable)
.orderBy(desc(stickerPacksTable.created_at))
}
export async function recordStickerPack(platformId: string, name: string, platform = 'telegram') {
await useDrizzle()
.insert(stickerPacksTable)
.values({
description: '',
name,
platform,
platform_id: platformId,
})
}
@@ -3,15 +3,6 @@ import { desc, eq, inArray } from 'drizzle-orm'
import { useDrizzle } from '../db'
import { recentSentStickersTable, stickersTable } from '../db/schema'
export async function findStickerDescription(fileId: string) {
const sticker = await findStickerByFileId(fileId)
if (sticker == null) {
return ''
}
return sticker.description
}
export async function findStickerByFileId(fileId: string) {
const sticker = await useDrizzle()
.select()
@@ -26,6 +17,15 @@ export async function findStickerByFileId(fileId: string) {
return sticker[0]
}
export async function findStickerDescription(fileId: string) {
const sticker = await findStickerByFileId(fileId)
if (sticker == null) {
return ''
}
return sticker.description
}
export async function findStickersByFileIds(fileIds: string[]) {
const stickers = await useDrizzle()
.select()
@@ -35,24 +35,24 @@ export async function findStickersByFileIds(fileIds: string[]) {
return stickers
}
export async function recordSticker(stickerBase64: string, fileId: string, filePath: string, description: string, name: string, emoji: string, label: string) {
await useDrizzle()
.insert(stickersTable)
.values({
platform: 'telegram',
file_id: fileId,
image_base64: stickerBase64,
image_path: filePath,
description,
name,
emoji,
label,
})
}
export async function listRecentSentStickers() {
return await useDrizzle()
.select()
.from(recentSentStickersTable)
.orderBy(desc(recentSentStickersTable.created_at))
}
export async function recordSticker(stickerBase64: string, fileId: string, filePath: string, description: string, name: string, emoji: string, label: string) {
await useDrizzle()
.insert(stickersTable)
.values({
description,
emoji,
file_id: fileId,
image_base64: stickerBase64,
image_path: filePath,
label,
name,
platform: 'telegram',
})
}
@@ -2,6 +2,14 @@ import { env } from 'node:process'
import { velin } from '../utils/velin'
export async function actionReadMessages(props: { lastMessages?: string, relevantChatMessages?: string, unreadHistoryMessages?: string }) {
return await (velin<{ lastMessages?: string, relevantChatMessages?: string, unreadHistoryMessages?: string }>('action-read-messages.velin.md', import.meta.url))(props)
}
export async function messageSplit() {
return await (velin<{ responseLanguage: string }>('message-split-v1.velin.md', import.meta.url))({ responseLanguage: env.LLM_RESPONSE_LANGUAGE })
}
export async function personality() {
return await (velin('personality-v1.velin.md', import.meta.url))()
}
@@ -9,11 +17,3 @@ export async function personality() {
export async function systemTicking() {
return await (velin<{ responseLanguage: string }>('system-ticking-v1.velin.md', import.meta.url))({ responseLanguage: env.LLM_RESPONSE_LANGUAGE })
}
export async function messageSplit() {
return await (velin<{ responseLanguage: string }>('message-split-v1.velin.md', import.meta.url))({ responseLanguage: env.LLM_RESPONSE_LANGUAGE })
}
export async function actionReadMessages(props: { lastMessages?: string, unreadHistoryMessages?: string, relevantChatMessages?: string }) {
return await (velin<{ lastMessages?: string, unreadHistoryMessages?: string, relevantChatMessages?: string }>('action-read-messages.velin.md', import.meta.url))(props)
}
+25 -25
View File
@@ -1,30 +1,6 @@
import type { TextContentPart } from '@xsai/shared-chat'
export function vif(condition: boolean, a: string, b = '') {
return condition ? a : b
}
export function vChoice(...args: [boolean | (() => boolean), string][]) {
for (let i = 0; i < args.length; i++) {
const exp = args[i][0]
if (typeof exp === 'function' ? exp() : exp) {
return args[i][1]
}
}
return ''
}
export function span(...args: string[]) {
return args
.map(arg => arg.trim())
.map(arg => arg.replaceAll(/\n\s+/g, ''))
.map(arg => arg.replaceAll(/\r\s+/g, ' '))
.join(' ')
}
export function div(...args: (string | TextContentPart | TextContentPart[] | null | undefined)[]) {
export function div(...args: (null | string | TextContentPart | TextContentPart[] | undefined)[]) {
const results: string[] = []
for (const arg of args) {
@@ -45,9 +21,33 @@ export function div(...args: (string | TextContentPart | TextContentPart[] | nul
return results.join('\n\n')
}
export function span(...args: string[]) {
return args
.map(arg => arg.trim())
.map(arg => arg.replaceAll(/\n\s+/g, ''))
.map(arg => arg.replaceAll(/\r\s+/g, ' '))
.join(' ')
}
// ul + li
export function ul(...args: string[]) {
return args.map((arg) => {
return `- ${arg}`
}).join('\n')
}
export function vChoice(...args: [(() => boolean) | boolean, string][]) {
for (let i = 0; i < args.length; i++) {
const exp = args[i][0]
if (typeof exp === 'function' ? exp() : exp) {
return args[i][1]
}
}
return ''
}
export function vif(condition: boolean, a: string, b = '') {
return condition ? a : b
}
+103 -103
View File
@@ -6,115 +6,33 @@ import type { Message } from 'grammy/types'
import type { CancellablePromise } from './utils/promise'
export interface PendingMessage {
message: Message
interpretationPromise?: Promise<void>
status: 'pending' | 'interpreting' | 'ready'
}
export type ExtendedContext = FileFlavor<Context>
export interface BotContext {
bot: Bot
messageQueue: Array<{
message: Message
status: 'pending' | 'interpreting' | 'ready'
}>
unreadMessages: Record<number, Message[]>
processedIds: Set<string>
logger: Logg
processing: boolean
lastInteractedNChatIds: string[]
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 {
action: 'continue'
}
export interface BreakAction {
action: 'break'
}
export interface SleepAction {
action: 'sleep'
}
export interface ListChatsAction {
action: 'list_chats'
}
export interface SendMessageAction {
action: 'send_message'
content: string
chatId: string
}
export interface SendStickerAction {
action: 'send_sticker'
fileId: string
chatId: string
}
export interface SearchGoogleAction {
action: 'search_google'
query: string
}
export interface ReadHistoryMessagesAction {
action: 'read_history_messages'
beforeMessageId?: string
afterMessageId?: string
chatId: string
}
export interface ReadUnreadMessagesAction {
action: 'read_unread_messages'
chatId: string
}
export interface ListStickersAction {
action: 'list_stickers'
}
export type Action
= | ContinueAction
| BreakAction
| SleepAction
= | BreakAction
| ContinueAction
| ListChatsAction
| SendMessageAction
| SendStickerAction
| SearchGoogleAction
| ListStickersAction
| ReadHistoryMessagesAction
| ReadUnreadMessagesAction
| ListStickersAction
| SearchGoogleAction
| SendMessageAction
| SendStickerAction
| SleepAction
export interface AttentionConfig {
initialResponseRate: number
responseRateMin: number
responseRateMax: number
cooldownMs: number
triggerWords: string[]
ignoreWords: string[]
decayRatePerMinute: number
decayCheckIntervalMs: number
decayRatePerMinute: number
ignoreWords: string[]
initialResponseRate: number
responseRateMax: number
responseRateMin: number
triggerWords: string[]
}
export interface AttentionStats {
mentionCount: number
triggerWordCount: number
lastInteractionTime: number
export interface AttentionResponse {
reason: string
responseRate?: number
shouldAct: boolean
}
export interface AttentionState {
@@ -123,8 +41,90 @@ export interface AttentionState {
stats: AttentionStats
}
export interface AttentionResponse {
shouldAct: boolean
reason: string
responseRate?: number
export interface AttentionStats {
lastInteractionTime: number
mentionCount: number
triggerWordCount: number
}
export interface BotContext {
bot: Bot
chats: Map<string, ChatContext>
currentProcessingStartTime?: number
lastInteractedNChatIds: string[]
logger: Logg
messageQueue: Array<{
message: Message
status: 'interpreting' | 'pending' | 'ready'
}>
processedIds: Set<string>
processing: boolean
unreadMessages: Record<number, Message[]>
}
export interface BreakAction {
action: 'break'
}
export interface ChatContext {
actions: { action: Action, result: unknown }[]
chatId: string
currentAbortController?: AbortController
currentTask?: CancellablePromise<Message.TextMessage>
messages: LLMMessage[]
}
export interface ContinueAction {
action: 'continue'
}
export type ExtendedContext = FileFlavor<Context>
export interface ListChatsAction {
action: 'list_chats'
}
export interface ListStickersAction {
action: 'list_stickers'
}
export interface PendingMessage {
interpretationPromise?: Promise<void>
message: Message
status: 'interpreting' | 'pending' | 'ready'
}
export interface ReadHistoryMessagesAction {
action: 'read_history_messages'
afterMessageId?: string
beforeMessageId?: string
chatId: string
}
export interface ReadUnreadMessagesAction {
action: 'read_unread_messages'
chatId: string
}
export interface SearchGoogleAction {
action: 'search_google'
query: string
}
export interface SendMessageAction {
action: 'send_message'
chatId: string
content: string
}
export interface SendStickerAction {
action: 'send_sticker'
chatId: string
fileId: string
}
export interface SleepAction {
action: 'sleep'
}
@@ -1,6 +1,6 @@
export interface CancellablePromise<T> {
promise: Promise<T>
cancel: () => void
promise: Promise<T>
}
export function cancellable<T>(promise: Promise<T>): CancellablePromise<T> {
@@ -12,7 +12,7 @@ export function cancellable<T>(promise: Promise<T>): CancellablePromise<T> {
})
return {
promise: wrappedPromise,
cancel: () => cancel?.(),
promise: wrappedPromise,
}
}
+4 -4
View File
@@ -8,10 +8,6 @@ export interface VelinModule {
render: <P>(data: P) => Promise<string>
}
function isMarkdown(module: string) {
return module.endsWith('.md') || module.endsWith('.velin.md')
}
export function importVelin(module: string, base: string): VelinModule {
return {
render: async (data) => {
@@ -30,3 +26,7 @@ export function importVelin(module: string, base: string): VelinModule {
export function velin<P = undefined>(module: string, base: string): (data?: P) => Promise<string> {
return importVelin(module, base).render
}
function isMarkdown(module: string) {
return module.endsWith('.md') || module.endsWith('.velin.md')
}
+2 -2
View File
@@ -14,10 +14,10 @@ export default defineConfig(({ mode }) => {
{
extends: true,
test: {
name: 'node',
environment: 'node',
include: ['**/*.{spec,test}.ts'],
exclude: ['**/*.browser.{spec,test}.ts', '**/node_modules/**'],
include: ['**/*.{spec,test}.ts'],
name: 'node',
},
},
],