diff --git a/src/slices/agent/agent/agent.module.ts b/src/slices/agent/agent/agent.module.ts index d4019a9..aea3097 100644 --- a/src/slices/agent/agent/agent.module.ts +++ b/src/slices/agent/agent/agent.module.ts @@ -9,7 +9,7 @@ export class AgentModule { this.service = new AgentService(new AgentGateway()) } - async buildPrompt(opts?: { userId?: string; toolingPrompt?: string; secretKeys?: string[]; dailyMemory?: string; skills?: SkillSummary[]; isAdmin?: boolean; extraHint?: string; integratorPrompt?: string }): Promise { + async buildPrompt(opts?: { userId?: string; toolingPrompt?: string; secretKeys?: string[]; dailyMemory?: string; skills?: SkillSummary[]; isAdmin?: boolean; extraHint?: string; integratorPrompt?: string; channelContext?: string }): Promise { return this.service.buildPrompt(this.agentDir, opts) } } diff --git a/src/slices/agent/agent/domain/agent.service.ts b/src/slices/agent/agent/domain/agent.service.ts index d883e78..d421f37 100644 --- a/src/slices/agent/agent/domain/agent.service.ts +++ b/src/slices/agent/agent/domain/agent.service.ts @@ -22,6 +22,11 @@ export interface BuildPromptOpts { * the agent is aware of what the embedding site passed in. */ integratorPrompt?: string + /** + * Describes the originating channel and sender so the agent knows where + * the conversation is happening (Telegram DM, Telegram group, Slack, etc.). + */ + channelContext?: string } const ADMIN_BLOCK_RE = /[\s\S]*?/g @@ -63,6 +68,7 @@ export class AgentService { const memory = gate(config.memory) if (soul) parts.push(`# Soul\n\n${soul}`) + if (opts?.channelContext) parts.push(`# Channel\n\n${opts.channelContext}`) if (opts?.extraHint) parts.push(opts.extraHint) if (opts?.toolingPrompt) parts.push(opts.toolingPrompt) if (agents) parts.push(`# Agent Instructions\n\n${agents}`) @@ -127,7 +133,7 @@ export class AgentService { return parts.join("\n\n---\n\n") } - async buildPrompt(agentDir: string, opts?: { userId?: string; toolingPrompt?: string; secretKeys?: string[]; dailyMemory?: string; skills?: SkillSummary[]; isAdmin?: boolean; extraHint?: string; integratorPrompt?: string }): Promise { + async buildPrompt(agentDir: string, opts?: { userId?: string; toolingPrompt?: string; secretKeys?: string[]; dailyMemory?: string; skills?: SkillSummary[]; isAdmin?: boolean; extraHint?: string; integratorPrompt?: string; channelContext?: string }): Promise { const config = await this.load(agentDir) // Override user context with per-user file if it exists @@ -145,6 +151,7 @@ export class AgentService { isAdmin: opts?.isAdmin, extraHint: opts?.extraHint, integratorPrompt: opts?.integratorPrompt, + channelContext: opts?.channelContext, }) } } diff --git a/src/slices/runtime/runtime/domain/runtime.service.ts b/src/slices/runtime/runtime/domain/runtime.service.ts index b65e8db..e09bdb9 100644 --- a/src/slices/runtime/runtime/domain/runtime.service.ts +++ b/src/slices/runtime/runtime/domain/runtime.service.ts @@ -121,6 +121,30 @@ export class RuntimeService { }, { internal: isInternal }) } + private buildChannelContext(msg: Message): string | undefined { + const { channel, metadata } = msg + if (channel === "internal") return undefined + + if (channel === "telegram") { + const isGroup = metadata?.isGroup as boolean | undefined + const username = metadata?.username as string | undefined + const fromName = metadata?.fromName as string | undefined + const chatTitle = metadata?.chatTitle as string | undefined + + if (isGroup) { + const groupDesc = chatTitle ? `"${chatTitle}"` : "a group" + const sender = fromName ?? (username ? `@${username}` : "someone") + return `You are responding in a Telegram group chat ${groupDesc}. This message is from ${sender}.` + } + const userDesc = username ? `@${username}` : "a user" + return `You are responding via Telegram (direct message) with ${userDesc}.` + } + + if (channel === "bridle") return "You are responding via the Bridle web embed." + if (channel === "slack") return "You are responding via Slack." + return `You are responding via ${channel}.` + } + private async buildHistory(msg: Message, sessionId: string, taskId: string): Promise { // Append user message as shared context const userEvent: Event = { @@ -206,6 +230,7 @@ export class RuntimeService { isAdmin, extraHint, integratorPrompt: msg.prompt, + channelContext: this.buildChannelContext(msg), }) // Inject full content for always-on skills diff --git a/src/slices/setup/channel/data/repositories/telegram/telegram.repository.ts b/src/slices/setup/channel/data/repositories/telegram/telegram.repository.ts index e78962d..5a74fae 100644 --- a/src/slices/setup/channel/data/repositories/telegram/telegram.repository.ts +++ b/src/slices/setup/channel/data/repositories/telegram/telegram.repository.ts @@ -9,21 +9,28 @@ interface TelegramUpdate { update_id: number message?: { message_id: number - from?: { id: number; username?: string } - chat: { id: number } + from?: { id: number; username?: string; first_name?: string; last_name?: string; is_bot?: boolean } + chat: { id: number; type: "private" | "group" | "supergroup" | "channel"; title?: string } text?: string date: number photo?: Array<{ file_id: string; file_size: number; width: number; height: number }> caption?: string document?: { file_id: string; file_name?: string; mime_type?: string; file_size?: number } + reply_to_message?: { + from?: { id: number; username?: string; is_bot?: boolean } + } } } +const GROUP_CONTEXT_MAX = 20 + export class TelegramRepository { private offset = 0 private running = false private handler?: (msg: Message) => Promise private baseUrl: string + private botUsername: string | null = null + private groupContext: Map> = new Map() constructor(private token: string) { this.baseUrl = `https://api.telegram.org/bot${token}` @@ -35,10 +42,24 @@ export class TelegramRepository { async start(): Promise { this.running = true + await this.fetchBotInfo() await this.registerCommands() this.poll() } + private async fetchBotInfo(): Promise { + try { + const res = await fetch(`${this.baseUrl}/getMe`) + const json = await res.json() as { ok: boolean; result: { username?: string } } + if (json.ok && json.result.username) { + this.botUsername = json.result.username.toLowerCase() + log.info(`bot username @${this.botUsername}`) + } + } catch (err) { + log.error("failed to fetch bot info", err) + } + } + private async registerCommands(): Promise { try { await fetch(`${this.baseUrl}/setMyCommands`, { @@ -187,6 +208,21 @@ export class TelegramRepository { } } + private addToGroupContext(chatId: string, name: string, text: string): void { + const buffer = this.groupContext.get(chatId) ?? [] + buffer.push({ name, text: text.slice(0, 200) }) + if (buffer.length > GROUP_CONTEXT_MAX) { + buffer.splice(0, buffer.length - GROUP_CONTEXT_MAX) + } + this.groupContext.set(chatId, buffer) + } + + private buildGroupContextString(chatId: string): string { + const buffer = this.groupContext.get(chatId) + if (!buffer?.length) return "" + return buffer.map(m => `${m.name}: ${m.text}`).join("\n") + } + private async poll(): Promise { log.info("polling started") while (this.running) { @@ -203,78 +239,124 @@ export class TelegramRepository { const hasText = !!msg?.text const hasPhoto = !!msg?.photo?.length const hasDocument = !!msg?.document - if ((hasText || hasPhoto || hasDocument) && this.handler) { - const chatId = String(msg!.chat.id) - const handler = this.handler - - // Process each message in parallel — don't block poll loop - ;(async () => { - await this.sendTyping(chatId) - const typingInterval = setInterval(() => this.sendTyping(chatId), 4000) - try { - let text = msg!.text ?? msg!.caption ?? (hasPhoto ? "[photo]" : "[document]") - const metadata: Record = { chatId: msg!.chat.id, username: msg!.from?.username } - - const images: Array<{ base64: string; mediaType: string }> = [] - - if (hasPhoto) { - const fileId = msg!.photo![msg!.photo!.length - 1].file_id - const fileRes = await fetch(`${this.baseUrl}/getFile?file_id=${fileId}`) - const fileJson = (await fileRes.json()) as { result: { file_path: string } } - const fileUrl = `https://api.telegram.org/file/bot${this.token}/${fileJson.result.file_path}` - metadata.photoUrl = fileUrl - metadata.hasPhoto = true - - // Download image and convert to base64 for native vision - try { - const imgRes = await fetch(fileUrl) - if (imgRes.ok) { - const buffer = await imgRes.arrayBuffer() - const base64 = Buffer.from(buffer).toString("base64") - // Claude API accepts only: image/jpeg, image/png, image/gif, image/webp - const rawType = (imgRes.headers.get("content-type") ?? "").split(";")[0].trim() - const validTypes = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]) - const mediaType = validTypes.has(rawType) ? rawType : "image/jpeg" - images.push({ base64, mediaType }) - } - } catch (err) { - log.error("failed to download photo for vision", err) - } + if (!((hasText || hasPhoto || hasDocument) && this.handler)) continue - const caption = msg!.caption ? msg!.caption : "" - text = caption || "[User sent a photo]" - } + const chatId = String(msg!.chat.id) + const isGroup = msg!.chat.type !== "private" + + // Group message handling: track context, only respond to @mentions/replies + let groupPriorContext = "" + let groupSenderName = "" + let groupChatTitle = "" + + if (isGroup) { + const rawText = msg!.text ?? msg!.caption ?? "" + groupSenderName = msg!.from?.username + ? `@${msg!.from.username}` + : (msg!.from?.first_name ?? "user") + groupChatTitle = msg!.chat.title ?? "" + + // Snapshot context BEFORE this message, then add this message + groupPriorContext = this.buildGroupContextString(chatId) + if (rawText) this.addToGroupContext(chatId, groupSenderName, rawText) - if (hasDocument) { - const doc = msg!.document! - const fileRes = await fetch(`${this.baseUrl}/getFile?file_id=${doc.file_id}`) - const fileJson = (await fileRes.json()) as { result: { file_path: string } } - const fileUrl = `https://api.telegram.org/file/bot${this.token}/${fileJson.result.file_path}` - const fileName = doc.file_name ?? fileJson.result.file_path.split("/").pop() ?? "file" - const localPath = `/tmp/${fileName}` - const fileData = await fetch(fileUrl) - await Bun.write(localPath, await fileData.arrayBuffer()) - metadata.documentPath = localPath - metadata.documentName = fileName - metadata.hasDocument = true - const caption = msg!.caption ? ` Caption: "${msg!.caption}"` : "" - text = `[User sent a file: ${fileName}]${caption}\nLocal path: ${localPath}\nProcess this file as needed.` + const isMentioned = !!this.botUsername && + rawText.toLowerCase().includes(`@${this.botUsername}`) + const isReplyToBot = + msg!.reply_to_message?.from?.username?.toLowerCase() === this.botUsername + + if (!isMentioned && !isReplyToBot) continue + } + + const handler = this.handler + + // Process each message in parallel — don't block poll loop + ;(async () => { + await this.sendTyping(chatId) + const typingInterval = setInterval(() => this.sendTyping(chatId), 4000) + try { + let text = msg!.text ?? msg!.caption ?? (hasPhoto ? "[photo]" : "[document]") + const metadata: Record = { + chatId: msg!.chat.id, + username: msg!.from?.username, + fromUserId: msg!.from?.id, + channel: isGroup ? "group" : "dm", + } + + const images: Array<{ base64: string; mediaType: string }> = [] + + if (hasPhoto) { + const fileId = msg!.photo![msg!.photo!.length - 1].file_id + const fileRes = await fetch(`${this.baseUrl}/getFile?file_id=${fileId}`) + const fileJson = (await fileRes.json()) as { result: { file_path: string } } + const fileUrl = `https://api.telegram.org/file/bot${this.token}/${fileJson.result.file_path}` + metadata.photoUrl = fileUrl + metadata.hasPhoto = true + + // Download image and convert to base64 for native vision + try { + const imgRes = await fetch(fileUrl) + if (imgRes.ok) { + const buffer = await imgRes.arrayBuffer() + const base64 = Buffer.from(buffer).toString("base64") + // Claude API accepts only: image/jpeg, image/png, image/gif, image/webp + const rawType = (imgRes.headers.get("content-type") ?? "").split(";")[0].trim() + const validTypes = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]) + const mediaType = validTypes.has(rawType) ? rawType : "image/jpeg" + images.push({ base64, mediaType }) + } + } catch (err) { + log.error("failed to download photo for vision", err) } - await handler(buildMessage({ - id: randomUUID(), - text, - from: chatId, - channel: "telegram", - ts: msg!.date * 1000, - ...(images.length > 0 ? { images } : {}), - metadata, - })) - } finally { - clearInterval(typingInterval) + const caption = msg!.caption ? msg!.caption : "" + text = caption || "[User sent a photo]" } - })().catch(err => log.error("message handler error", err)) - } + + if (hasDocument) { + const doc = msg!.document! + const fileRes = await fetch(`${this.baseUrl}/getFile?file_id=${doc.file_id}`) + const fileJson = (await fileRes.json()) as { result: { file_path: string } } + const fileUrl = `https://api.telegram.org/file/bot${this.token}/${fileJson.result.file_path}` + const fileName = doc.file_name ?? fileJson.result.file_path.split("/").pop() ?? "file" + const localPath = `/tmp/${fileName}` + const fileData = await fetch(fileUrl) + await Bun.write(localPath, await fileData.arrayBuffer()) + metadata.documentPath = localPath + metadata.documentName = fileName + metadata.hasDocument = true + const caption = msg!.caption ? ` Caption: "${msg!.caption}"` : "" + text = `[User sent a file: ${fileName}]${caption}\nLocal path: ${localPath}\nProcess this file as needed.` + } + + // Group-specific: strip @mention and prepend discussion context + if (isGroup) { + if (this.botUsername) { + text = text.replace(new RegExp(`@${this.botUsername}`, "gi"), "").trim() + } + if (groupPriorContext) { + text = `[Recent group discussion]\n${groupPriorContext}\n\n[From ${groupSenderName}] ${text || "(no text)"}` + } else { + text = `[From ${groupSenderName} in group] ${text}` + } + metadata.isGroup = true + metadata.fromName = groupSenderName + metadata.chatTitle = groupChatTitle + } + + await handler(buildMessage({ + id: randomUUID(), + text, + from: chatId, + channel: "telegram", + ts: msg!.date * 1000, + ...(images.length > 0 ? { images } : {}), + metadata, + })) + } finally { + clearInterval(typingInterval) + } + })().catch(err => log.error("message handler error", err)) } } catch { await this.wait(5000)