286 lines
7.0 KiB
Go
286 lines
7.0 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 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
|
|
}
|