From ee40fbbbe163172c6c5ad6b44af49962e4e918af Mon Sep 17 00:00:00 2001 From: azep-ninja Date: Thu, 12 Dec 2024 21:15:56 -0700 Subject: [PATCH] Add Telegram team features. --- packages/client-telegram/src/constants.ts | 38 ++ .../client-telegram/src/messageManager.ts | 463 +++++++++++++++++- packages/client-telegram/src/utils.ts | 97 ++++ packages/core/src/types.ts | 5 + 4 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 packages/client-telegram/src/constants.ts create mode 100644 packages/client-telegram/src/utils.ts diff --git a/packages/client-telegram/src/constants.ts b/packages/client-telegram/src/constants.ts new file mode 100644 index 0000000000..f377019e1a --- /dev/null +++ b/packages/client-telegram/src/constants.ts @@ -0,0 +1,38 @@ +export const MESSAGE_CONSTANTS = { + MAX_MESSAGES: 50, + RECENT_MESSAGE_COUNT: 5, + CHAT_HISTORY_COUNT: 10, + DEFAULT_SIMILARITY_THRESHOLD: 0.6, + DEFAULT_SIMILARITY_THRESHOLD_FOLLOW_UPS: 0.4, + INTEREST_DECAY_TIME: 5 * 60 * 1000, // 5 minutes + PARTIAL_INTEREST_DECAY: 3 * 60 * 1000, // 3 minutes +} as const; + +export const TIMING_CONSTANTS = { + TEAM_MEMBER_DELAY: 1500, // 1.5 seconds + TEAM_MEMBER_DELAY_MIN: 1000, // 1 second + TEAM_MEMBER_DELAY_MAX: 3000, // 3 seconds + LEADER_DELAY_MIN: 2000, // 2 seconds + LEADER_DELAY_MAX: 4000 // 4 seconds +} as const; + +export const RESPONSE_CHANCES = { + AFTER_LEADER: 0.5, // 50% chance to respond after leader +} as const; + +export const TEAM_COORDINATION = { + KEYWORDS: [ + 'team', + 'everyone', + 'all agents', + 'team update', + 'gm team', + 'hello team', + 'hey team', + 'hi team', + 'morning team', + 'evening team', + 'night team', + 'update team', + ] +} as const; \ No newline at end of file diff --git a/packages/client-telegram/src/messageManager.ts b/packages/client-telegram/src/messageManager.ts index 6c400d514e..de2894859f 100644 --- a/packages/client-telegram/src/messageManager.ts +++ b/packages/client-telegram/src/messageManager.ts @@ -18,6 +18,14 @@ import { stringToUuid } from "@ai16z/eliza"; import { generateMessageResponse, generateShouldRespond } from "@ai16z/eliza"; import { messageCompletionFooter, shouldRespondFooter } from "@ai16z/eliza"; +import { cosineSimilarity } from "./utils"; +import { + MESSAGE_CONSTANTS, + TIMING_CONSTANTS, + RESPONSE_CHANCES, + TEAM_COORDINATION +} from "./constants"; + const MAX_MESSAGE_LENGTH = 4096; // Telegram's max message length const telegramShouldRespondTemplate = @@ -133,13 +141,223 @@ Thread of Tweets You Are Replying To: {{formattedConversation}} ` + messageCompletionFooter; +interface MessageContext { + content: string; + timestamp: number; +} + +export type InterestChats = { + [key: string]: { + currentHandler: string | undefined; + lastMessageSent: number; + messages: { userId: UUID; userName: string; content: Content }[]; + previousContext?: MessageContext; + contextSimilarityThreshold?: number; + }; +}; + export class MessageManager { public bot: Telegraf; private runtime: IAgentRuntime; + private interestChats: InterestChats = {}; + private teamMemberUsernames: Map = new Map(); constructor(bot: Telegraf, runtime: IAgentRuntime) { this.bot = bot; this.runtime = runtime; + + this._initializeTeamMemberUsernames().catch(error => + elizaLogger.error("Error initializing team member usernames:", error) + ); + } + + private async _initializeTeamMemberUsernames(): Promise { + if (!this.runtime.character.clientConfig?.telegram?.isPartOfTeam) return; + + const teamAgentIds = this.runtime.character.clientConfig.telegram.teamAgentIds || []; + + for (const id of teamAgentIds) { + try { + const chat = await this.bot.telegram.getChat(id); + if ('username' in chat && chat.username) { + this.teamMemberUsernames.set(id, chat.username); + elizaLogger.info(`Cached username for team member ${id}: ${chat.username}`); + } + } catch (error) { + elizaLogger.error(`Error getting username for team member ${id}:`, error); + } + } + } + + private _getTeamMemberUsername(id: string): string | undefined { + return this.teamMemberUsernames.get(id); + } + + private _getNormalizedUserId(id: string | number): string { + return id.toString().replace(/[^0-9]/g, ''); + } + + private _isTeamMember(userId: string | number): boolean { + const teamConfig = this.runtime.character.clientConfig?.telegram; + if (!teamConfig?.isPartOfTeam || !teamConfig.teamAgentIds) return false; + + const normalizedUserId = this._getNormalizedUserId(userId); + return teamConfig.teamAgentIds.some(teamId => + this._getNormalizedUserId(teamId) === normalizedUserId + ); + } + + private _isTeamLeader(): boolean { + return this.bot.botInfo?.id.toString() === this.runtime.character.clientConfig?.telegram?.teamLeaderId; + } + + private _isTeamCoordinationRequest(content: string): boolean { + const contentLower = content.toLowerCase(); + return TEAM_COORDINATION.KEYWORDS?.some(keyword => + contentLower.includes(keyword.toLowerCase()) + ); + } + + private _isRelevantToTeamMember(content: string, chatId: string, lastAgentMemory: Memory | null = null): boolean { + const teamConfig = this.runtime.character.clientConfig?.telegram; + + // Check leader's context based on last message + if (this._isTeamLeader() && lastAgentMemory?.content.text) { + const timeSinceLastMessage = Date.now() - lastAgentMemory.createdAt; + if (timeSinceLastMessage > MESSAGE_CONSTANTS.INTEREST_DECAY_TIME) { + return false; + } + + const similarity = cosineSimilarity( + content.toLowerCase(), + lastAgentMemory.content.text.toLowerCase() + ); + + return similarity >= MESSAGE_CONSTANTS.DEFAULT_SIMILARITY_THRESHOLD_FOLLOW_UPS; + } + + // Check team member keywords + if (!teamConfig?.teamMemberInterestKeywords?.length) { + return false; // If no keywords defined, only leader maintains conversation + } + + // Check if content matches any team member keywords + return teamConfig.teamMemberInterestKeywords.some(keyword => + content.toLowerCase().includes(keyword.toLowerCase()) + ); + } + + private async _analyzeContextSimilarity(currentMessage: string, previousContext?: MessageContext, agentLastMessage?: string): Promise { + if (!previousContext) return 1; + + const timeDiff = Date.now() - previousContext.timestamp; + const timeWeight = Math.max(0, 1 - (timeDiff / (5 * 60 * 1000))); + + const similarity = cosineSimilarity( + currentMessage.toLowerCase(), + previousContext.content.toLowerCase(), + agentLastMessage?.toLowerCase() + ); + + return similarity * timeWeight; + } + + private async _shouldRespondBasedOnContext(message: Message, chatState: InterestChats[string]): Promise { + const messageText = 'text' in message ? message.text : + 'caption' in message ? (message as any).caption : ''; + + if (!messageText) return false; + + // Always respond if mentioned + if (this._isMessageForMe(message)) return true; + + // If we're not the current handler, don't respond + if (chatState?.currentHandler !== this.bot.botInfo?.id.toString()) return false; + + // Check if we have messages to compare + if (!chatState.messages?.length) return false; + + // Get last user message (not from the bot) + const lastUserMessage = [...chatState.messages] + .reverse() + .find((m, index) => + index > 0 && // Skip first message (current) + m.userId !== this.runtime.agentId + ); + + if (!lastUserMessage) return false; + + const lastSelfMemories = await this.runtime.messageManager.getMemories({ + roomId: stringToUuid(message.chat.id.toString() + "-" + this.runtime.agentId), + unique: false, + count: 5 + }); + + const lastSelfSortedMemories = lastSelfMemories?.filter(m => m.userId === this.runtime.agentId) + .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); + + // Calculate context similarity + const contextSimilarity = await this._analyzeContextSimilarity( + messageText, + { + content: lastUserMessage.content.text || '', + timestamp: Date.now() + }, + lastSelfSortedMemories?.[0]?.content?.text + ); + + const similarityThreshold = + this.runtime.character.clientConfig?.telegram?.messageSimilarityThreshold || + chatState.contextSimilarityThreshold || + MESSAGE_CONSTANTS.DEFAULT_SIMILARITY_THRESHOLD; + + return contextSimilarity >= similarityThreshold; + } + + private _isMessageForMe(message: Message): boolean { + const botUsername = this.bot.botInfo?.username; + if (!botUsername) return false; + + const messageText = 'text' in message ? message.text : + 'caption' in message ? (message as any).caption : ''; + if (!messageText) return false; + + const isMentioned = messageText.includes(`@${botUsername}`); + const hasUsername = messageText.toLowerCase().includes(botUsername.toLowerCase()); + + return isMentioned || (!this.runtime.character.clientConfig?.telegram?.shouldRespondOnlyToMentions && hasUsername); + } + + private _checkInterest(chatId: string): boolean { + const chatState = this.interestChats[chatId]; + if (!chatState) return false; + + const lastMessage = chatState.messages[chatState.messages.length - 1]; + const timeSinceLastMessage = Date.now() - chatState.lastMessageSent; + + if (timeSinceLastMessage > MESSAGE_CONSTANTS.INTEREST_DECAY_TIME) { + delete this.interestChats[chatId]; + return false; + } else if (timeSinceLastMessage > MESSAGE_CONSTANTS.PARTIAL_INTEREST_DECAY) { + return this._isRelevantToTeamMember(lastMessage?.content.text || '', chatId); + } + + // Team leader specific checks + if (this._isTeamLeader() && chatState.messages.length > 0) { + if (!this._isRelevantToTeamMember(lastMessage?.content.text || '', chatId)) { + const recentTeamResponses = chatState.messages.slice(-3).some(m => + m.userId !== this.runtime.agentId && + this._isTeamMember(m.userId.toString()) + ); + + if (recentTeamResponses) { + delete this.interestChats[chatId]; + return false; + } + } + } + + return true; } // Process image messages and generate descriptions @@ -149,6 +367,8 @@ export class MessageManager { try { let imageUrl: string | null = null; + elizaLogger.info(`Telegram Message: ${message}`) + if ("photo" in message && message.photo?.length > 0) { const photo = message.photo[message.photo.length - 1]; const fileLink = await this.bot.telegram.getFileLink( @@ -186,11 +406,17 @@ export class MessageManager { message: Message, state: State ): Promise { + + if (this.runtime.character.clientConfig?.telegram?.shouldRespondOnlyToMentions) { + return this._isMessageForMe(message); + } + // Respond if bot is mentioned if ( "text" in message && message.text?.includes(`@${this.bot.botInfo?.username}`) ) { + elizaLogger.info(`Bot mentioned`) return true; } @@ -208,6 +434,123 @@ export class MessageManager { return false; } + const chatId = message.chat.id.toString(); + const chatState = this.interestChats[chatId]; + const messageText = 'text' in message ? message.text : + 'caption' in message ? (message as any).caption : ''; + + // Check if team member has direct interest first + if (this.runtime.character.clientConfig?.discord?.isPartOfTeam && + !this._isTeamLeader() && + this._isRelevantToTeamMember(messageText, chatId)) { + + return true; + } + + // Team-based response logic + if (this.runtime.character.clientConfig?.telegram?.isPartOfTeam) { + // Team coordination + if(this._isTeamCoordinationRequest(messageText)) { + if (this._isTeamLeader()) { + return true; + } else { + const randomDelay = Math.floor(Math.random() * (TIMING_CONSTANTS.TEAM_MEMBER_DELAY_MAX - TIMING_CONSTANTS.TEAM_MEMBER_DELAY_MIN)) + + TIMING_CONSTANTS.TEAM_MEMBER_DELAY_MIN; // 1-3 second random delay + await new Promise(resolve => setTimeout(resolve, randomDelay)); + return true; + } + } + + if (!this._isTeamLeader() && this._isRelevantToTeamMember(messageText, chatId)) { + // Add small delay for non-leader responses + await new Promise(resolve => setTimeout(resolve, TIMING_CONSTANTS.TEAM_MEMBER_DELAY)); //1.5 second delay + + // If leader has responded in last few seconds, reduce chance of responding + if (chatState.messages?.length) { + const recentMessages = chatState.messages.slice(-MESSAGE_CONSTANTS.RECENT_MESSAGE_COUNT); + const leaderResponded = recentMessages.some(m => + m.userId === this.runtime.character.clientConfig?.telegram?.teamLeaderId && + Date.now() - chatState.lastMessageSent < 3000 + ); + + if (leaderResponded) { + // 50% chance to respond if leader just did + return Math.random() > RESPONSE_CHANCES.AFTER_LEADER; + } + } + + return true; + } + + // If I'm the leader but message doesn't match my keywords, add delay and check for team responses + if (this._isTeamLeader() && !this._isRelevantToTeamMember(messageText, chatId)) { + const randomDelay = Math.floor(Math.random() * (TIMING_CONSTANTS.LEADER_DELAY_MAX - TIMING_CONSTANTS.LEADER_DELAY_MIN)) + + TIMING_CONSTANTS.LEADER_DELAY_MIN; // 2-4 second random delay + await new Promise(resolve => setTimeout(resolve, randomDelay)); + + // After delay, check if another team member has already responded + if (chatState?.messages?.length) { + const recentResponses = chatState.messages.slice(-MESSAGE_CONSTANTS.RECENT_MESSAGE_COUNT); + const otherTeamMemberResponded = recentResponses.some(m => + m.userId !== this.runtime.agentId && + this._isTeamMember(m.userId) + ); + + if (otherTeamMemberResponded) { + return false; + } + } + } + + // Update current handler if we're mentioned + if (this._isMessageForMe(message)) { + const channelState = this.interestChats[chatId]; + if (channelState) { + channelState.currentHandler = this.bot.botInfo?.id.toString() + channelState.lastMessageSent = Date.now(); + } + return true; + } + + // Don't respond if another teammate is handling the conversation + if (chatState?.currentHandler) { + if (chatState.currentHandler !== this.bot.botInfo?.id.toString() && + this._isTeamMember(chatState.currentHandler)) { + return false; + } + } + + // Natural conversation cadence + if (!this._isMessageForMe(message) && this.interestChats[chatId]) { + + const recentMessages = this.interestChats[chatId].messages + .slice(-MESSAGE_CONSTANTS.CHAT_HISTORY_COUNT); + const ourMessageCount = recentMessages.filter(m => + m.userId === this.runtime.agentId + ).length; + + if (ourMessageCount > 2) { + + const responseChance = Math.pow(0.5, ourMessageCount - 2); + if (Math.random() > responseChance) { + return; + } + } + } + + } + + // Check context-based response for team conversations + if (chatState?.currentHandler) { + const shouldRespondContext = await this._shouldRespondBasedOnContext(message, chatState); + + if (!shouldRespondContext) { + return false; + } + + } + + // Use AI to decide for text or captions if ("text" in message || ("caption" in message && message.caption)) { const shouldRespondContext = composeContext({ @@ -329,6 +672,124 @@ export class MessageManager { } const message = ctx.message; + const chatId = ctx.chat?.id.toString(); + const messageText = 'text' in message ? message.text : + 'caption' in message ? (message as any).caption : ''; + + // Add team handling at the start + if (this.runtime.character.clientConfig?.telegram?.isPartOfTeam && + !this.runtime.character.clientConfig?.telegram?.shouldRespondOnlyToMentions) { + + const isDirectlyMentioned = this._isMessageForMe(message); + const hasInterest = this._checkInterest(chatId); + + + // Non-leader team member showing interest based on keywords + if (!this._isTeamLeader() && this._isRelevantToTeamMember(messageText, chatId)) { + + this.interestChats[chatId] = { + currentHandler: this.bot.botInfo?.id.toString(), + lastMessageSent: Date.now(), + messages: [] + }; + } + + const isTeamRequest = this._isTeamCoordinationRequest(messageText); + const isLeader = this._isTeamLeader(); + + + // Check for continued interest + if (hasInterest && !isDirectlyMentioned) { + const lastSelfMemories = await this.runtime.messageManager.getMemories({ + roomId: stringToUuid(chatId + "-" + this.runtime.agentId), + unique: false, + count: 5 + }); + + const lastSelfSortedMemories = lastSelfMemories?.filter(m => m.userId === this.runtime.agentId) + .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); + + const isRelevant = this._isRelevantToTeamMember( + messageText, + chatId, + lastSelfSortedMemories?.[0] + ); + + if (!isRelevant) { + delete this.interestChats[chatId]; + return; + } + } + + // Handle team coordination requests + if (isTeamRequest) { + if (isLeader) { + this.interestChats[chatId] = { + currentHandler: this.bot.botInfo?.id.toString(), + lastMessageSent: Date.now(), + messages: [] + }; + } else { + this.interestChats[chatId] = { + currentHandler: this.bot.botInfo?.id.toString(), + lastMessageSent: Date.now(), + messages: [] + }; + + if (!isDirectlyMentioned) { + this.interestChats[chatId].lastMessageSent = 0; + } + + } + } + + // Check for other team member mentions using cached usernames + const otherTeamMembers = this.runtime.character.clientConfig.telegram.teamAgentIds.filter( + id => id !== this.bot.botInfo?.id.toString() + ); + + const mentionedTeamMember = otherTeamMembers.find(id => { + const username = this._getTeamMemberUsername(id); + return username && messageText?.includes(`@${username}`); + }); + + // If another team member is mentioned, clear our interest + if (mentionedTeamMember) { + if (hasInterest || this.interestChats[chatId]?.currentHandler === this.bot.botInfo?.id.toString()) { + delete this.interestChats[chatId]; + + // Only return if we're not the mentioned member + if (!isDirectlyMentioned) { + return; + } + } + } + + // Set/maintain interest only if we're mentioned or already have interest + if (isDirectlyMentioned) { + this.interestChats[chatId] = { + currentHandler: this.bot.botInfo?.id.toString(), + lastMessageSent: Date.now(), + messages: [] + }; + } else if (!isTeamRequest && !hasInterest) { + return; + } + + // Update message tracking + if (this.interestChats[chatId]) { + this.interestChats[chatId].messages.push({ + userId: stringToUuid(ctx.from.id.toString()), + userName: ctx.from.username || ctx.from.first_name || "Unknown User", + content: { text: messageText, source: "telegram" } + }); + + if (this.interestChats[chatId].messages.length > MESSAGE_CONSTANTS.MAX_MESSAGES) { + this.interestChats[chatId].messages = + this.interestChats[chatId].messages.slice(-MESSAGE_CONSTANTS.MAX_MESSAGES); + } + } + } try { // Convert IDs to UUIDs @@ -505,4 +966,4 @@ export class MessageManager { elizaLogger.error("Error sending message:", error); } } -} +} \ No newline at end of file diff --git a/packages/client-telegram/src/utils.ts b/packages/client-telegram/src/utils.ts new file mode 100644 index 0000000000..86f0278f0e --- /dev/null +++ b/packages/client-telegram/src/utils.ts @@ -0,0 +1,97 @@ +export function cosineSimilarity(text1: string, text2: string, text3?: string): number { + const preprocessText = (text: string) => text + .toLowerCase() + .replace(/[^\w\s'_-]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + const getWords = (text: string) => { + return text.split(' ').filter(word => word.length > 1); + }; + + const words1 = getWords(preprocessText(text1)); + const words2 = getWords(preprocessText(text2)); + const words3 = text3 ? getWords(preprocessText(text3)) : []; + + const freq1: { [key: string]: number } = {}; + const freq2: { [key: string]: number } = {}; + const freq3: { [key: string]: number } = {}; + + words1.forEach(word => freq1[word] = (freq1[word] || 0) + 1); + words2.forEach(word => freq2[word] = (freq2[word] || 0) + 1); + if (words3.length) { + words3.forEach(word => freq3[word] = (freq3[word] || 0) + 1); + } + + const uniqueWords = new Set([...Object.keys(freq1), ...Object.keys(freq2), ...(words3.length ? Object.keys(freq3) : [])]); + + let dotProduct = 0; + let magnitude1 = 0; + let magnitude2 = 0; + let magnitude3 = 0; + + uniqueWords.forEach(word => { + const val1 = freq1[word] || 0; + const val2 = freq2[word] || 0; + const val3 = freq3[word] || 0; + + if (words3.length) { + // For three-way, calculate pairwise similarities + const sim12 = val1 * val2; + const sim23 = val2 * val3; + const sim13 = val1 * val3; + + // Take maximum similarity between any pair + dotProduct += Math.max(sim12, sim23, sim13); + } else { + dotProduct += val1 * val2; + } + + magnitude1 += val1 * val1; + magnitude2 += val2 * val2; + if (words3.length) { + magnitude3 += val3 * val3; + } + }); + + magnitude1 = Math.sqrt(magnitude1); + magnitude2 = Math.sqrt(magnitude2); + magnitude3 = words3.length ? Math.sqrt(magnitude3) : 1; + + if (magnitude1 === 0 || magnitude2 === 0 || (words3.length && magnitude3 === 0)) return 0; + + // For two texts, use original calculation + if (!words3.length) { + return dotProduct / (magnitude1 * magnitude2); + } + + // For three texts, use max magnitude pair to maintain scale + const maxMagnitude = Math.max( + magnitude1 * magnitude2, + magnitude2 * magnitude3, + magnitude1 * magnitude3 + ); + + return dotProduct / maxMagnitude; +} + +/** + * Splits a message into chunks that fit within Telegram's message length limit + */ +export function splitMessage(text: string, maxLength: number = 4096): string[] { + const chunks: string[] = []; + let currentChunk = ""; + + const lines = text.split("\n"); + for (const line of lines) { + if (currentChunk.length + line.length + 1 <= maxLength) { + currentChunk += (currentChunk ? "\n" : "") + line; + } else { + if (currentChunk) chunks.push(currentChunk); + currentChunk = line; + } + } + + if (currentChunk) chunks.push(currentChunk); + return chunks; +} \ No newline at end of file diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b9790e98f7..7aa35bdebb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -717,6 +717,11 @@ export type Character = { telegram?: { shouldIgnoreBotMessages?: boolean; shouldIgnoreDirectMessages?: boolean; + messageSimilarityThreshold?: number; + isPartOfTeam?: boolean; + teamAgentIds?: string[]; + teamLeaderId?: string; + teamMemberInterestKeywords?: string[]; }; };