From 625c2382b790951f560d6da9f8b3275886524476 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Mon, 20 Apr 2026 09:33:03 +0200 Subject: [PATCH] feat(telegram): mirror tui prompts into telegram thread When the telegram bridge is connected, messages you type in the zot tui now also appear in the paired chat so the telegram transcript stays a complete record of the session. Format: you: <- from tui editor, grey bubble zot: <- reply to a tui prompt <- your own blue bubble <- reply to a telegram dm, no prefix The "zot: " prefix is only attached when the turn was initiated from the tui side. Telegram-initiated turns reply bare so the thread reads as a normal back-and-forth with the bot; the "you: " bubble from the tui side would otherwise pair awkwardly with a DM-initiated bare reply. Implementation is small: bridge.go - OnUserTyped(text): sends with "you: " prefix. Called from the interactive submit path when the bridge is active. - OnAssistantText(text): sends with "zot: " prefix by default, or bare when nextReplyFromTelegram is set. - nextReplyFromTelegram is flipped to true inside handleUpdate right before calling Host.SubmitOrQueue, and back to false when the reply is flushed. One-slot flag, safe against the actual serial turn drain the agent uses. - On Start(), if Config.AllowedUserID is already known from a previous session, prepopulate chatID so the bridge can send immediately without waiting for a handshake DM (private-chat id == user id on telegram). - sendToPaired consolidates the chunk-and-send plumbing so OnUserTyped, OnAssistantText, and future tap points share one path. interactive.go - The editor submit path now calls telegramBridge.OnUserTyped on a goroutine (network write off the event loop) before queuing or starting the turn. No-op when the bridge is stopped or no chat is paired. No user-visible setup change: /telegram connect / disconnect / status work the same; the two-way mirror is automatic once connected. --- internal/agent/modes/interactive.go | 8 ++++ internal/agent/modes/telegram/bridge.go | 54 +++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index d5d7817..6f3630e 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -1091,6 +1091,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { i.mu.Unlock() return false } + // Mirror the user's typed prompt into the paired Telegram + // chat (when the bridge is active) so the Telegram thread + // stays a complete record of the session, not just the half + // that originated on the phone. On a goroutine so the + // network write doesn't delay the local turn. + if i.telegramBridge != nil && i.telegramBridge.Active() { + go i.telegramBridge.OnUserTyped(text) + } // If a turn is already in flight, queue this prompt instead of // starting a second one. The drain loop at the end of startTurn // will pick it up when the current turn finishes. diff --git a/internal/agent/modes/telegram/bridge.go b/internal/agent/modes/telegram/bridge.go index b2e6d10..402ab0d 100644 --- a/internal/agent/modes/telegram/bridge.go +++ b/internal/agent/modes/telegram/bridge.go @@ -45,6 +45,14 @@ type Bridge struct { me *User chatID int64 // populated after first DM from the paired user replyBuf strings.Builder + + // nextReplyFromTelegram is set when the next assistant reply + // should be sent bare (no "zot: " prefix) because the turn was + // initiated by a Telegram DM. The flag clears as soon as the + // reply is flushed. TUI-originated turns leave the flag false + // so the reply is tagged "zot: " for clarity on the two-sided + // transcript. + nextReplyFromTelegram bool } // State is the snapshot /telegram status reports. @@ -106,6 +114,12 @@ func (b *Bridge) Start(parent context.Context) error { b.running = true b.cancel = cancel b.me = me + // Telegram private-chat ids are the same as the user id, so if + // we've already paired in a previous session we can send to the + // user immediately without waiting for them to DM first. + if b.Config.AllowedUserID != 0 && b.chatID == 0 { + b.chatID = b.Config.AllowedUserID + } if b.Config.BotID != me.ID || b.Config.BotUsername != me.Username { b.Config.BotID = me.ID b.Config.BotUsername = me.Username @@ -131,10 +145,37 @@ func (b *Bridge) Stop() { // OnAssistantText should be called by the TUI with the assistant's // final visible text for each turn. The bridge forwards it to the -// paired chat in message-sized chunks. Safe to call from any -// goroutine; a no-op when the bridge is stopped or no chat is -// known yet. +// paired chat in message-sized chunks. Prefix depends on which +// side initiated the turn: TUI-originated turns get "zot: " so the +// two-sided transcript reads naturally ("you: ..." / "zot: ..."), +// while Telegram-originated turns send bare text (the user's own +// bubble is already on-screen, a "zot: " prefix would just add +// visual noise to a plain back-and-forth). func (b *Bridge) OnAssistantText(text string) { + b.mu.Lock() + prefix := "zot: " + if b.nextReplyFromTelegram { + prefix = "" + b.nextReplyFromTelegram = false + } + b.mu.Unlock() + b.sendToPaired(text, prefix) +} + +// OnUserTyped mirrors a message the user typed in the zot TUI into +// the paired Telegram chat, tagged "you:" so the Telegram thread +// stays a complete record of the conversation (both TUI-originated +// and Telegram-originated turns). Messages sent from Telegram +// itself aren't mirrored back (they already appear as the user's +// own bubble), only TUI-originated prompts flow through here. +func (b *Bridge) OnUserTyped(text string) { + b.sendToPaired(text, "you: ") +} + +// sendToPaired writes text (with an optional prefix, chunked to +// Telegram's 4096-char cap) to the paired chat. No-op when the +// bridge is stopped or before the paired chat id is known. +func (b *Bridge) sendToPaired(text, prefix string) { b.mu.Lock() chatID := b.chatID running := b.running @@ -146,7 +187,9 @@ func (b *Bridge) OnAssistantText(text string) { if text == "" { return } - // Telegram caps at 4096 chars; chunk to be safe. + if prefix != "" { + text = prefix + text + } for _, chunk := range chunkMessage(text, 4000) { if err := b.Client.SendMessage(context.Background(), chatID, chunk, 0); err != nil { fmt.Fprintln(stderr(), "telegram bridge: sendMessage:", err) @@ -290,6 +333,9 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { return } + b.mu.Lock() + b.nextReplyFromTelegram = true + b.mu.Unlock() b.Host.SubmitOrQueue(prompt, images) }