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 ExpandableQuoteRawHTML(htmlText string) string { htmlText = strings.TrimSpace(htmlText) if htmlText == "" { return "" } return "
" + htmlText + "
" } 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 SummaryRawHTMLSections(summary string, sections []string) string { summary = strings.TrimSpace(summary) sections = nonEmptyHTML(sections) var parts []string if summary != "" { parts = append(parts, EscapeHTML(summary)) } for _, section := range sections { parts = append(parts, ExpandableQuoteRawHTML(section)) } return strings.Join(parts, "\n") } func SummaryRawHTMLSectionsLimited(summary string, sections []string, limit int) string { if limit <= 0 { limit = TelegramHTMLMessageLimit } return FitHTMLMessage(SummaryRawHTMLSections(summary, sections), limit) } func CodeBlockHTML(language, text string) string { text = strings.TrimSpace(text) if text == "" { return "" } language = safeCodeLanguage(language) return "
" + EscapeHTML(text) + "
" } func FieldHTML(label, value string) string { value = strings.TrimSpace(value) if value == "" { return "" } return "" + EscapeHTML(label) + ": " + 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( "
", "", "
", "", "", "", "", "", "", "", "", "", "", "", "", "", ) text := replacer.Replace(htmlText) for { start := strings.Index(text, "") 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 = "
" const close = "
" const truncatedQuote = "...[truncated]" for len([]rune(htmlText)) > limit { contentStart, contentEnd, content := largestBlockquoteContent(htmlText, open, close) if contentStart < 0 { return truncateHTMLText(htmlText, limit) } prefix, body, codeLanguage, codeBlock := splitSafeQuotePrefix(content) contentRunes := []rune(strings.TrimSpace(html.UnescapeString(body))) if len(contentRunes) == 0 { replacement := prefix + EscapeHTML(truncatedQuote) if replacement == content { return summaryOnlyHTML(htmlText, limit) } htmlText = htmlText[:contentStart] + replacement + htmlText[contentEnd:] continue } 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 := prefix + EscapeHTML(replacementText) if codeBlock { replacement = prefix + CodeBlockHTML(codeLanguage, replacementText) } if replacement == content { return summaryOnlyHTML(htmlText, limit) } htmlText = htmlText[:contentStart] + replacement + htmlText[contentEnd:] } return htmlText } func splitSafeQuotePrefix(content string) (prefix, body, codeLanguage string, codeBlock bool) { content = strings.TrimSpace(content) prefix, body = splitLeadingBoldLabel(content) body = strings.TrimSpace(body) if language, code, ok := splitSingleCodeBlock(body); ok { return prefix, code, language, true } return prefix, body, "", false } func splitLeadingBoldLabel(content string) (string, string) { if !strings.HasPrefix(content, "") { return "", content } end := strings.Index(content, "") if end < 0 { return "", content } labelEnd := end + len("") label := content[:labelEnd] if !strings.HasSuffix(label, ":") { return "", content } afterLabel := content[labelEnd:] if strings.HasPrefix(afterLabel, "
") {
		return label + " ", strings.TrimLeft(afterLabel, " ")
	}
	if strings.HasPrefix(afterLabel, "\n
") {
		return label + " ", strings.TrimLeft(afterLabel, "\n")
	}
	if strings.HasPrefix(afterLabel, " ") {
		lineEnd := strings.Index(afterLabel, "\n")
		if lineEnd < 0 {
			return content + "\n", ""
		}
		prefixEnd := labelEnd + lineEnd + 1
		return content[:prefixEnd], strings.TrimLeft(content[prefixEnd:], "\n")
	}
	rest := strings.TrimLeft(afterLabel, " \n")
	return label + " ", rest
}

func splitSingleCodeBlock(content string) (string, string, bool) {
	const preOpen = "
"
	const preClose = "
" const codeClose = "
" if !strings.HasPrefix(content, preOpen+"") if tagEnd < 0 { return "", "", false } tagEnd += codeStart tag := content[codeStart : tagEnd+1] const classPrefix = `class="language-` classStart := strings.Index(tag, classPrefix) if classStart < 0 { return "", "", false } classStart += len(classPrefix) classEnd := strings.Index(tag[classStart:], `"`) if classEnd < 0 { return "", "", false } language := tag[classStart : classStart+classEnd] bodyStart := tagEnd + 1 bodyEnd := len(content) - len(codeClose+preClose) if bodyEnd < bodyStart { return "", "", false } return safeCodeLanguage(language), html.UnescapeString(content[bodyStart:bodyEnd]), true } 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]" if len([]rune(htmlText)) <= limit { return htmlText } plain := stripSimpleHTML(htmlText) runes := []rune(plain) keep := limit - len([]rune(suffix)) if keep < 0 { keep = 0 } if keep > len(runes) { keep = len(runes) } return EscapeHTML(strings.TrimSpace(string(runes[:keep]))) + EscapeHTML(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", "acceptWithExecpolicyAmendment", "decline", "cancel", "details": return id, parts[2], true default: if strings.HasPrefix(parts[2], "networkPolicy") { index, err := strconv.Atoi(strings.TrimPrefix(parts[2], "networkPolicy")) return id, parts[2], err == nil && index >= 0 } return 0, "", false } } func SandboxCallbackData(sandbox string) string { return "sandbox:" + sandbox } func ParseSandboxCallbackData(data string) (string, bool) { if !strings.HasPrefix(data, "sandbox:") { return "", false } sandbox := strings.TrimPrefix(data, "sandbox:") switch sandbox { case "read-only", "workspace-write", "danger-full-access": return sandbox, true default: return "", 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 }