diff --git a/internal/telegram/api.go b/internal/telegram/api.go index 2801451..f73d0de 100644 --- a/internal/telegram/api.go +++ b/internal/telegram/api.go @@ -60,6 +60,25 @@ func (c *Client) SendMessage(ctx context.Context, chatID int64, text string, opt return message, nil } +func (c *Client) SendChatAction(ctx context.Context, chatID int64, action string) error { + params := map[string]any{ + "chat_id": chatID, + "action": action, + } + var ok bool + return c.postJSON(ctx, "sendChatAction", params, &ok) +} + +func (c *Client) SendMessageDraft(ctx context.Context, chatID int64, draftID int64, text string) error { + params := map[string]any{ + "chat_id": chatID, + "draft_id": draftID, + "text": text, + } + var ok bool + return c.postJSON(ctx, "sendMessageDraft", params, &ok) +} + func (c *Client) EditMessageText(ctx context.Context, chatID int64, messageID int, text string, opts EditMessageTextOptions) (Message, error) { params := map[string]any{ "chat_id": chatID, diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index dec933e..8744f8b 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -42,9 +42,20 @@ type Bot struct { } type outputState struct { - chatID int64 - assistant strings.Builder - sentAny bool + chatID int64 + assistant strings.Builder + sentAny bool + tools map[string]toolMessageState + workingIndicatorOff context.CancelFunc +} + +type toolMessageState struct { + chatID int64 + messageID int + toolHTML string + approvalHTML string + approvalMarkup *InlineKeyboardMarkup + editedAt string } type codexThreadItemView struct { @@ -1039,9 +1050,9 @@ func (b *Bot) handleApprovalCallback(ctx context.Context, callback *CallbackQuer if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Sent to Codex."); err != nil { return err } - updated := renderApprovalHTML(approval.Kind, json.RawMessage(approval.PayloadJSON), approvalStatusLine(decision)) + updated := b.resolveApprovalMessageHTML(approval, decision) _, err = b.tg.EditMessageText(ctx, callback.Message.Chat.ID, callback.Message.MessageID, updated, EditMessageTextOptions{ParseMode: "HTML"}) - return err + return ignoreTelegramMessageNotModified(err) } func (b *Bot) handleCodexEvents(ctx context.Context) { @@ -1288,7 +1299,7 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event) return b.flushAssistantMessage(ctx, params.ThreadID) } if params.ThreadID != "" { - return b.sendOutputHTMLBlock(ctx, params.ThreadID, renderCodexItemStarted(item)) + return b.upsertToolMessage(ctx, params.ThreadID, item.ID, renderCodexItemStarted(item)) } case "item/agentMessage/delta": var params struct { @@ -1322,7 +1333,7 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event) return b.flushAssistantMessage(ctx, params.ThreadID) } if params.ThreadID != "" { - return b.sendOutputHTMLBlock(ctx, params.ThreadID, renderCodexItemCompleted(item)) + return b.upsertToolMessage(ctx, params.ThreadID, item.ID, renderCodexItemCompleted(item)) } case "turn/diff/updated": var params struct { @@ -1424,9 +1435,16 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event return err } text := renderApprovalHTML(kind, event.Params, "") + markup := approvalMarkup(approval.ID) + if msg, ok, err := b.attachApprovalToToolMessage(ctx, params.ThreadID, params.ItemID, text, markup); err != nil { + return err + } else if ok { + return b.store.UpdatePendingApprovalMessage(ctx, approval.ID, msg.Chat.ID, msg.MessageID) + } + msg, err := b.tg.SendMessage(ctx, thread.TelegramUserID, text, SendMessageOptions{ ParseMode: "HTML", - ReplyMarkup: approvalMarkup(approval.ID), + ReplyMarkup: markup, }) if err != nil { return err @@ -1434,16 +1452,73 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event return b.store.UpdatePendingApprovalMessage(ctx, approval.ID, msg.Chat.ID, msg.MessageID) } +func (b *Bot) newOutputState(chatID int64) *outputState { + return &outputState{ + chatID: chatID, + tools: make(map[string]toolMessageState), + workingIndicatorOff: b.startWorkingIndicator(chatID), + } +} + func (b *Bot) registerOutput(threadID string, chatID int64) { b.mu.Lock() defer b.mu.Unlock() - b.outputs[threadID] = &outputState{chatID: chatID} + if state := b.outputs[threadID]; state != nil && state.workingIndicatorOff != nil { + state.workingIndicatorOff() + } + b.outputs[threadID] = b.newOutputState(chatID) } func (b *Bot) clearOutput(threadID string) { b.mu.Lock() - defer b.mu.Unlock() + state := b.outputs[threadID] delete(b.outputs, threadID) + b.mu.Unlock() + if state != nil && state.workingIndicatorOff != nil { + state.workingIndicatorOff() + } +} + +func (b *Bot) startWorkingIndicator(chatID int64) context.CancelFunc { + ctx, cancel := context.WithCancel(context.Background()) + draftID := time.Now().UnixNano() + go func() { + useDraft := true + sendDraft := func() bool { + return b.tg.SendMessageDraft(ctx, chatID, draftID, "") == nil + } + sendTyping := func() { + if err := b.tg.SendChatAction(ctx, chatID, "typing"); err != nil && ctx.Err() == nil { + b.logger.Printf("send typing action: %v", err) + } + } + + if !sendDraft() { + useDraft = false + sendTyping() + } + + draftTicker := time.NewTicker(25 * time.Second) + typingTicker := time.NewTicker(4 * time.Second) + defer draftTicker.Stop() + defer typingTicker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-draftTicker.C: + if useDraft && !sendDraft() { + useDraft = false + sendTyping() + } + case <-typingTicker.C: + if !useDraft { + sendTyping() + } + } + } + }() + return cancel } func (b *Bot) hasAssistantText(threadID string) bool { @@ -1513,6 +1588,182 @@ func (b *Bot) sendOutputHTMLBlock(ctx context.Context, threadID, htmlText string return nil } +func (s toolMessageState) html() string { + return FitHTMLMessage(addEditedAtLine(combineToolApprovalHTML(s.toolHTML, s.approvalHTML), s.editedAt), TelegramHTMLMessageLimit) +} + +func combineToolApprovalHTML(toolHTML, approvalHTML string) string { + toolHTML = strings.TrimSpace(toolHTML) + approvalHTML = strings.TrimSpace(approvalHTML) + switch { + case toolHTML == "": + return approvalHTML + case approvalHTML == "": + return toolHTML + default: + return toolHTML + "\n\n" + approvalHTML + } +} + +func addEditedAtLine(htmlText, editedAt string) string { + htmlText = strings.TrimSpace(htmlText) + if htmlText == "" || editedAt == "" { + return htmlText + } + line := EscapeHTML("Edited at: " + editedAt) + quoteIndex := strings.Index(htmlText, "
") + if quoteIndex < 0 { + return htmlText + "\n" + line + } + summary := strings.TrimRight(htmlText[:quoteIndex], "\n") + details := strings.TrimLeft(htmlText[quoteIndex:], "\n") + if summary == "" { + return line + "\n" + details + } + return summary + "\n" + line + "\n" + details +} + +func editedAtTimestamp() string { + return time.Now().UTC().Format("2006-01-02 15:04:05 MST") +} + +func (b *Bot) upsertToolMessage(ctx context.Context, threadID, itemID, htmlText string) error { + htmlText = strings.TrimSpace(htmlText) + if htmlText == "" { + return nil + } + if itemID == "" { + return b.sendOutputHTMLBlock(ctx, threadID, htmlText) + } + if err := b.flushAssistantMessage(ctx, threadID); err != nil { + return err + } + chatID, err := b.outputChatID(ctx, threadID) + if err != nil { + return nil + } + + b.mu.Lock() + state := b.outputs[threadID] + if state != nil && state.tools == nil { + state.tools = make(map[string]toolMessageState) + } + if state != nil { + tool, ok := state.tools[itemID] + if ok && tool.messageID != 0 { + tool.toolHTML = htmlText + tool.editedAt = editedAtTimestamp() + state.tools[itemID] = tool + combined := tool.html() + msgChatID := tool.chatID + msgID := tool.messageID + markup := tool.approvalMarkup + b.mu.Unlock() + _, err := b.tg.EditMessageText(ctx, msgChatID, msgID, combined, EditMessageTextOptions{ParseMode: "HTML", ReplyMarkup: markup}) + if err := ignoreTelegramMessageNotModified(err); err != nil { + return err + } + b.markOutputSent(threadID) + return nil + } + } + b.mu.Unlock() + + msg, err := b.sendHTMLMessage(ctx, chatID, htmlText, nil) + if err != nil { + return err + } + b.mu.Lock() + state = b.outputs[threadID] + if state != nil { + if state.tools == nil { + state.tools = make(map[string]toolMessageState) + } + state.tools[itemID] = toolMessageState{chatID: msg.Chat.ID, messageID: msg.MessageID, toolHTML: htmlText} + } + b.mu.Unlock() + b.markOutputSent(threadID) + return nil +} + +func (b *Bot) attachApprovalToToolMessage(ctx context.Context, threadID, itemID, approvalHTML string, markup *InlineKeyboardMarkup) (Message, bool, error) { + approvalHTML = strings.TrimSpace(approvalHTML) + if threadID == "" || itemID == "" || approvalHTML == "" { + return Message{}, false, nil + } + if err := b.flushAssistantMessage(ctx, threadID); err != nil { + return Message{}, false, err + } + + b.mu.Lock() + state := b.outputs[threadID] + if state == nil { + b.mu.Unlock() + return Message{}, false, nil + } + tool, ok := state.tools[itemID] + if !ok || tool.messageID == 0 { + b.mu.Unlock() + return Message{}, false, nil + } + tool.approvalHTML = approvalHTML + tool.approvalMarkup = markup + tool.editedAt = editedAtTimestamp() + state.tools[itemID] = tool + combined := tool.html() + msg := Message{MessageID: tool.messageID, Chat: Chat{ID: tool.chatID}} + b.mu.Unlock() + + _, err := b.tg.EditMessageText(ctx, msg.Chat.ID, msg.MessageID, combined, EditMessageTextOptions{ParseMode: "HTML", ReplyMarkup: markup}) + if err := ignoreTelegramMessageNotModified(err); err != nil { + b.clearToolApproval(threadID, itemID) + b.logger.Printf("edit tool approval message %s/%s: %v", threadID, itemID, err) + return Message{}, false, nil + } + b.markOutputSent(threadID) + return msg, true, nil +} + +func (b *Bot) clearToolApproval(threadID, itemID string) { + b.mu.Lock() + defer b.mu.Unlock() + if state := b.outputs[threadID]; state != nil { + if tool, ok := state.tools[itemID]; ok { + tool.approvalHTML = "" + tool.approvalMarkup = nil + state.tools[itemID] = tool + } + } +} + +func (b *Bot) resolveApprovalMessageHTML(approval store.PendingApproval, decision string) string { + approvalHTML := renderApprovalHTML(approval.Kind, json.RawMessage(approval.PayloadJSON), approvalStatusLine(decision)) + if approval.ItemID == "" { + return approvalHTML + } + b.mu.Lock() + defer b.mu.Unlock() + state := b.outputs[approval.CodexThreadID] + if state == nil { + return approvalHTML + } + tool, ok := state.tools[approval.ItemID] + if !ok || tool.messageID == 0 || tool.messageID != approval.MessageID { + return approvalHTML + } + tool.approvalHTML = approvalHTML + tool.approvalMarkup = nil + tool.editedAt = editedAtTimestamp() + state.tools[approval.ItemID] = tool + return tool.html() +} + +func ignoreTelegramMessageNotModified(err error) error { + if err != nil && strings.Contains(err.Error(), "message is not modified") { + return nil + } + return err +} func (b *Bot) appendAssistantDelta(ctx context.Context, threadID, delta string) error { if delta == "" { return nil @@ -1560,8 +1811,12 @@ func (b *Bot) completeTurnOutput(ctx context.Context, threadID string) error { } chatID := state.chatID sentAny := state.sentAny + workingIndicatorOff := state.workingIndicatorOff delete(b.outputs, threadID) b.mu.Unlock() + if workingIndicatorOff != nil { + workingIndicatorOff() + } if !sentAny { _, err := b.tg.SendMessage(ctx, chatID, "Done.", SendMessageOptions{}) @@ -1606,10 +1861,14 @@ func (b *Bot) sendLong(ctx context.Context, chatID int64, text string) error { } func (b *Bot) sendHTML(ctx context.Context, chatID int64, htmlText string) error { - _, err := b.tg.SendMessage(ctx, chatID, htmlText, SendMessageOptions{ParseMode: "HTML"}) + _, err := b.sendHTMLMessage(ctx, chatID, htmlText, nil) return err } +func (b *Bot) sendHTMLMessage(ctx context.Context, chatID int64, htmlText string, markup *InlineKeyboardMarkup) (Message, error) { + return b.tg.SendMessage(ctx, chatID, htmlText, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup}) +} + func (b *Bot) sendError(ctx context.Context, chatID int64, prefix string, err error) error { _, sendErr := b.tg.SendMessage(ctx, chatID, EscapeHTML(prefix+": "+err.Error()), SendMessageOptions{ParseMode: "HTML"}) return sendErr diff --git a/internal/telegram/render.go b/internal/telegram/render.go index 9600bbe..292caaa 100644 --- a/internal/telegram/render.go +++ b/internal/telegram/render.go @@ -66,6 +66,108 @@ func SummaryDetailsHTMLLimited(summary, details string, limit int) string { return SummaryDetailsHTML(summary, suffix) } +func FitHTMLMessage(htmlText string, limit int) string { + if limit <= 0 { + limit = TelegramHTMLMessageLimit + } + htmlText = strings.TrimSpace(htmlText) + if len([]rune(htmlText)) <= limit { + return htmlText + } + + const open = "
" + const close = "
" + const truncatedQuote = "...[truncated]" + for len([]rune(htmlText)) > limit { + contentStart, contentEnd, content := largestBlockquoteContent(htmlText, open, close) + if contentStart < 0 { + return truncateHTMLText(htmlText, limit) + } + + contentRunes := []rune(strings.TrimSpace(html.UnescapeString(content))) + over := len([]rune(htmlText)) - limit + keep := len(contentRunes) - over - 80 + if keep < 0 { + keep = 0 + } + if keep >= len(contentRunes) { + keep = len(contentRunes) - 1 + } + replacementText := truncatedQuote + if keep > 0 { + replacementText = strings.TrimSpace(string(contentRunes[:keep])) + "\n" + truncatedQuote + } + replacement := EscapeHTML(replacementText) + if replacement == content { + return summaryOnlyHTML(htmlText, limit) + } + htmlText = htmlText[:contentStart] + replacement + htmlText[contentEnd:] + } + return htmlText +} + +func largestBlockquoteContent(htmlText, open, close string) (int, int, string) { + bestStart := -1 + bestEnd := -1 + bestContent := "" + searchFrom := 0 + for { + start := strings.Index(htmlText[searchFrom:], open) + if start < 0 { + break + } + start += searchFrom + contentStart := start + len(open) + end := strings.Index(htmlText[contentStart:], close) + if end < 0 { + break + } + end += contentStart + content := htmlText[contentStart:end] + if bestStart < 0 || len([]rune(content)) > len([]rune(bestContent)) { + bestStart = contentStart + bestEnd = end + bestContent = content + } + searchFrom = end + len(close) + } + return bestStart, bestEnd, bestContent +} + +func summaryOnlyHTML(htmlText string, limit int) string { + const open = "
" + const close = "
" + for { + start := strings.Index(htmlText, open) + if start < 0 { + break + } + end := strings.Index(htmlText[start+len(open):], close) + if end < 0 { + break + } + end += start + len(open) + len(close) + htmlText = strings.TrimRight(htmlText[:start], "\n") + "\n" + strings.TrimLeft(htmlText[end:], "\n") + } + return truncateHTMLText(strings.TrimSpace(htmlText), limit) +} + +func truncateHTMLText(htmlText string, limit int) string { + if limit <= 0 { + limit = TelegramHTMLMessageLimit + } + suffix := "\n...[truncated]" + runes := []rune(htmlText) + if len(runes) <= limit { + return htmlText + } + keep := limit - len([]rune(suffix)) + if keep < 0 { + keep = 0 + } + return string(runes[:keep]) + suffix +} + func ChunkText(text string, max int) []string { if max <= 0 { max = TelegramMessageLimit diff --git a/internal/telegram/render_test.go b/internal/telegram/render_test.go index 5e321c8..31a56a6 100644 --- a/internal/telegram/render_test.go +++ b/internal/telegram/render_test.go @@ -101,6 +101,37 @@ func TestRenderCodexStartedItems(t *testing.T) { } } +func TestToolMessageAddsEditedAtBeforeDetails(t *testing.T) { + tool := toolMessageState{ + toolHTML: SummaryDetailsHTML("Tool call: command finished\nCommand: go test ./...", "full output"), + editedAt: "2026-05-21 12:34:56 UTC", + } + text := tool.html() + want := "Command: go test ./...\nEdited at: 2026-05-21 12:34:56 UTC\n
" + if !strings.Contains(text, want) { + t.Fatalf("edited timestamp not placed before details: %q", text) + } +} + +func TestToolMessageFitsCombinedApprovalDetails(t *testing.T) { + largeToolDetails := strings.Repeat("tool output line\n", 600) + largeApprovalDetails := strings.Repeat("approval payload line\n", 600) + tool := toolMessageState{ + toolHTML: SummaryDetailsHTML("Tool call: command finished", largeToolDetails), + approvalHTML: SummaryDetailsHTML("Codex requests command approval", largeApprovalDetails), + editedAt: "2026-05-21 12:34:56 UTC", + } + text := tool.html() + if len([]rune(text)) > TelegramHTMLMessageLimit { + t.Fatalf("tool message exceeds Telegram limit: %d", len([]rune(text))) + } + for _, want := range []string{"Tool call: command finished", "Codex requests command approval", "Edited at: 2026-05-21 12:34:56 UTC", "...[truncated]"} { + if !strings.Contains(text, want) { + t.Fatalf("fitted tool message missing %q in %q", want, text) + } + } +} + func TestResumeCallbackData(t *testing.T) { threadID, ok := ParseResumeThreadCallbackData(ResumeThreadCallbackData(123)) if !ok || threadID != 123 {