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: <what you typed>         <- from tui editor, grey bubble
  zot: <assistant reply>        <- reply to a tui prompt
  <your telegram dm>            <- your own blue bubble
  <assistant reply, bare>       <- 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.
This commit is contained in:
patriceckhart 2026-04-20 09:33:03 +02:00
parent 098a79743d
commit 625c2382b7
2 changed files with 58 additions and 4 deletions

View file

@ -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.

View file

@ -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)
}