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 "
" + EscapeHTML(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 = "
" 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 } 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 }