Files
codex-telegram-bot/internal/telegram/render.go
2026-05-24 03:05:25 +00:00

399 lines
9.8 KiB
Go

package telegram
import (
"encoding/base64"
"fmt"
"html"
"strconv"
"strings"
)
const TelegramMessageLimit = 4096
const TelegramHTMLMessageLimit = 3900
func EscapeHTML(text string) string {
return html.EscapeString(text)
}
func SummaryDetailsHTML(summary, details string) string {
summary = strings.TrimSpace(summary)
details = strings.TrimSpace(details)
if details == "" {
return EscapeHTML(summary)
}
if summary == "" {
return ExpandableQuoteHTML(details)
}
return EscapeHTML(summary) + "\n" + ExpandableQuoteHTML(details)
}
func ExpandableQuoteHTML(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
return "<blockquote expandable>" + EscapeHTML(text) + "</blockquote>"
}
func ExpandableQuoteRawHTML(htmlText string) string {
htmlText = strings.TrimSpace(htmlText)
if htmlText == "" {
return ""
}
return "<blockquote expandable>" + htmlText + "</blockquote>"
}
func SummaryDetailsRawHTML(summary, detailsHTML string) string {
summary = strings.TrimSpace(summary)
detailsHTML = strings.TrimSpace(detailsHTML)
if detailsHTML == "" {
return EscapeHTML(summary)
}
if summary == "" {
return ExpandableQuoteRawHTML(detailsHTML)
}
return EscapeHTML(summary) + "\n" + ExpandableQuoteRawHTML(detailsHTML)
}
func SummaryDetailsRawHTMLLimited(summary, detailsHTML string, limit int) string {
if limit <= 0 {
limit = TelegramHTMLMessageLimit
}
summary = strings.TrimSpace(summary)
detailsHTML = strings.TrimSpace(detailsHTML)
out := SummaryDetailsRawHTML(summary, detailsHTML)
if len([]rune(out)) <= limit || detailsHTML == "" {
return out
}
plain := stripSimpleHTML(detailsHTML)
suffix := "\n...[truncated]"
runes := []rune(plain)
for len(runes) > 0 {
candidateLen := len(runes) - max(1, (len([]rune(out))-limit)/2)
if candidateLen < 0 {
candidateLen = 0
}
if candidateLen > len(runes) {
candidateLen = len(runes)
}
candidate := CodeBlockHTML("text", strings.TrimSpace(string(runes[:candidateLen]))+suffix)
out = SummaryDetailsRawHTML(summary, candidate)
if len([]rune(out)) <= limit || candidateLen == 0 {
return out
}
runes = runes[:candidateLen]
}
return SummaryDetailsRawHTML(summary, EscapeHTML(suffix))
}
func CodeBlockHTML(language, text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
language = safeCodeLanguage(language)
return "<pre><code class=\"language-" + language + "\">" + EscapeHTML(text) + "</code></pre>"
}
func FieldHTML(label, value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
return "<b>" + EscapeHTML(label) + ":</b> " + EscapeHTML(value)
}
func safeCodeLanguage(language string) string {
language = strings.ToLower(strings.TrimSpace(language))
if language == "" {
return "text"
}
var builder strings.Builder
for _, r := range language {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '-', r == '_':
builder.WriteRune(r)
}
}
if builder.Len() == 0 {
return "text"
}
return builder.String()
}
func stripSimpleHTML(htmlText string) string {
replacer := strings.NewReplacer(
"<pre>", "", "</pre>", "",
"<code>", "", "</code>", "",
"<b>", "", "</b>", "",
"<i>", "", "</i>", "",
)
text := replacer.Replace(htmlText)
for {
start := strings.Index(text, "<code ")
if start < 0 {
break
}
end := strings.Index(text[start:], ">")
if end < 0 {
break
}
text = text[:start] + text[start+end+1:]
}
return html.UnescapeString(text)
}
func SummaryDetailsHTMLLimited(summary, details string, limit int) string {
if limit <= 0 {
limit = TelegramHTMLMessageLimit
}
summary = strings.TrimSpace(summary)
details = strings.TrimSpace(details)
out := SummaryDetailsHTML(summary, details)
if len([]rune(out)) <= limit || details == "" {
return out
}
suffix := "\n...[truncated]"
runes := []rune(details)
for len(runes) > 0 {
candidateLen := len(runes) - max(1, (len([]rune(out))-limit)/2)
if candidateLen < 0 {
candidateLen = 0
}
if candidateLen > len(runes) {
candidateLen = len(runes)
}
candidate := strings.TrimSpace(string(runes[:candidateLen])) + suffix
out = SummaryDetailsHTML(summary, candidate)
if len([]rune(out)) <= limit || candidateLen == 0 {
return out
}
runes = runes[:candidateLen]
}
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 = "<blockquote expandable>"
const close = "</blockquote>"
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 = "<blockquote expandable>"
const close = "</blockquote>"
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
}
runes := []rune(text)
if len(runes) == 0 {
return nil
}
var chunks []string
for len(runes) > max {
cut := max
for i := max; i > max/2; i-- {
if runes[i-1] == '\n' {
cut = i
break
}
}
chunks = append(chunks, string(runes[:cut]))
runes = runes[cut:]
}
if len(runes) > 0 {
chunks = append(chunks, string(runes))
}
return chunks
}
func ApprovalCallbackData(id int64, decision string) string {
return fmt.Sprintf("approval:%d:%s", id, decision)
}
func ParseApprovalCallbackData(data string) (int64, string, bool) {
parts := strings.Split(data, ":")
if len(parts) != 3 || parts[0] != "approval" {
return 0, "", false
}
id, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || id <= 0 {
return 0, "", false
}
switch parts[2] {
case "accept", "acceptForSession", "decline", "cancel", "details":
return id, parts[2], true
default:
return 0, "", false
}
}
func WorkspaceCallbackData(id int64) string {
return fmt.Sprintf("workspace:%d", id)
}
func ParseWorkspaceCallbackData(data string) (int64, bool) {
if !strings.HasPrefix(data, "workspace:") {
return 0, false
}
id, err := strconv.ParseInt(strings.TrimPrefix(data, "workspace:"), 10, 64)
return id, err == nil && id > 0
}
func ResumeThreadCallbackData(id int64) string {
return fmt.Sprintf("resume:thread:%d", id)
}
func ParseResumeThreadCallbackData(data string) (int64, bool) {
if !strings.HasPrefix(data, "resume:thread:") {
return 0, false
}
id, err := strconv.ParseInt(strings.TrimPrefix(data, "resume:thread:"), 10, 64)
return id, err == nil && id > 0
}
func ResumePageCallbackData(page int) string {
return fmt.Sprintf("resume:page:%d", page)
}
func ParseResumePageCallbackData(data string) (int, bool) {
if !strings.HasPrefix(data, "resume:page:") {
return 0, false
}
page, err := strconv.Atoi(strings.TrimPrefix(data, "resume:page:"))
return page, err == nil && page >= 0
}
func ModelCallbackData(modelID string) (string, bool) {
encoded := base64.RawURLEncoding.EncodeToString([]byte(modelID))
data := "model:" + encoded
return data, len([]rune(data)) <= 64
}
func ParseModelCallbackData(data string) (string, bool) {
if !strings.HasPrefix(data, "model:") {
return "", false
}
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(data, "model:"))
if err != nil || len(decoded) == 0 {
return "", false
}
return string(decoded), true
}
func EffortCallbackData(effort string) string {
encoded := base64.RawURLEncoding.EncodeToString([]byte(effort))
return "effort:" + encoded
}
func ParseEffortCallbackData(data string) (string, bool) {
if !strings.HasPrefix(data, "effort:") {
return "", false
}
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(data, "effort:"))
if err != nil || len(decoded) == 0 {
return "", false
}
return string(decoded), true
}