Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/slices/agent/agent/agent.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
async buildPrompt(opts?: { userId?: string; toolingPrompt?: string; secretKeys?: string[]; dailyMemory?: string; skills?: SkillSummary[]; isAdmin?: boolean; extraHint?: string; integratorPrompt?: string; channelContext?: string }): Promise<string> {
return this.service.buildPrompt(this.agentDir, opts)
}
}
9 changes: 8 additions & 1 deletion src/slices/agent/agent/domain/agent.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*admin-only\s*-->[\s\S]*?<!--\s*\/admin-only\s*-->/g
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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<string> {
async buildPrompt(agentDir: string, opts?: { userId?: string; toolingPrompt?: string; secretKeys?: string[]; dailyMemory?: string; skills?: SkillSummary[]; isAdmin?: boolean; extraHint?: string; integratorPrompt?: string; channelContext?: string }): Promise<string> {
const config = await this.load(agentDir)

// Override user context with per-user file if it exists
Expand All @@ -145,6 +151,7 @@ export class AgentService {
isAdmin: opts?.isAdmin,
extraHint: opts?.extraHint,
integratorPrompt: opts?.integratorPrompt,
channelContext: opts?.channelContext,
})
}
}
25 changes: 25 additions & 0 deletions src/slices/runtime/runtime/domain/runtime.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Event[]> {
// Append user message as shared context
const userEvent: Event = {
Expand Down Expand Up @@ -206,6 +230,7 @@ export class RuntimeService {
isAdmin,
extraHint,
integratorPrompt: msg.prompt,
channelContext: this.buildChannelContext(msg),
})

// Inject full content for always-on skills
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
private baseUrl: string
private botUsername: string | null = null
private groupContext: Map<string, Array<{ name: string; text: string }>> = new Map()

constructor(private token: string) {
this.baseUrl = `https://api.telegram.org/bot${token}`
Expand All @@ -35,10 +42,24 @@ export class TelegramRepository {

async start(): Promise<void> {
this.running = true
await this.fetchBotInfo()
await this.registerCommands()
this.poll()
}

private async fetchBotInfo(): Promise<void> {
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<void> {
try {
await fetch(`${this.baseUrl}/setMyCommands`, {
Expand Down Expand Up @@ -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<void> {
log.info("polling started")
while (this.running) {
Expand All @@ -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<string, unknown> = { 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<string, unknown> = {
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)
Expand Down