This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit bd18272015ebbbd04e54c5d767befc6cf46f57e3 Author: Yang Luo <[email protected]> AuthorDate: Sun Sep 6 17:53:40 2026 +0800 feat(channels): keep an agent's Markdown formatting in Telegram --- imbridge/telegram.go | 33 +++++- imbridge/telegram_html.go | 295 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+), 2 deletions(-) diff --git a/imbridge/telegram.go b/imbridge/telegram.go index d06dcc4..67024f5 100644 --- a/imbridge/telegram.go +++ b/imbridge/telegram.go @@ -159,6 +159,9 @@ func (t *Telegram) Send(message Message, reply Reply) (string, error) { // Telegram refuses anything over its limit outright, so a long answer is cut // into messages and only the last one stays editable. + // The cut is made on the Markdown rather than on the rendered HTML: tags only + // ever shorten what a reader sees, so a chunk within the limit here is within + // it after rendering too, and no tag is ever sliced in half. chunks := splitForChat(text, telegramMessageLimit) last := "" for index, chunk := range chunks { @@ -166,14 +169,31 @@ func (t *Telegram) Send(message Message, reply Reply) (string, error) { MessageId int64 `json:"message_id"` }{} - method, body := "sendMessage", map[string]any{"chat_id": message.ChatId, "text": chunk} + method := "sendMessage" + body := map[string]any{ + "chat_id": message.ChatId, + "text": telegramHtml(chunk), + "parse_mode": "HTML", + // A link in an answer would otherwise pull in a preview card, redrawn + // on every edit while the answer is still growing. + "link_preview_options": map[string]any{"is_disabled": true}, + } // Only the first chunk can replace what is already there; the rest are // new messages under it. if reply.Edit != "" && index == 0 { method = "editMessageText" body["message_id"] = reply.Edit } - if err := t.call(context.Background(), method, body, &result); err != nil { + + err := t.call(context.Background(), method, body, &result) + // Nothing an agent writes is worth losing to a markup complaint: the same + // message goes again as the plain text it was. + if err != nil && isParseError(err) { + delete(body, "parse_mode") + body["text"] = chunk + err = t.call(context.Background(), method, body, &result) + } + if err != nil { return last, err } last = strconv.FormatInt(result.MessageId, 10) @@ -194,6 +214,15 @@ func isWebhookConflict(err error) bool { return err != nil && strings.Contains(err.Error(), "webhook is active") } +// isParseError recognises Telegram refusing the markup rather than the message. +func isParseError(err error) bool { + if err == nil { + return false + } + lowered := strings.ToLower(err.Error()) + return strings.Contains(lowered, "parse entities") || strings.Contains(lowered, "tag") && strings.Contains(lowered, "entities") +} + // dropWebhook takes the bot's webhook down. Updates already waiting are kept: // they are dropped a moment later by the backlog skip, which is the one place // that decides what is too old to answer. diff --git a/imbridge/telegram_html.go b/imbridge/telegram_html.go new file mode 100644 index 0000000..a2bdf45 --- /dev/null +++ b/imbridge/telegram_html.go @@ -0,0 +1,295 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package imbridge + +import ( + "strings" +) + +// telegramHtml renders the Markdown an agent writes into the small HTML subset +// Telegram parses. HTML is chosen over MarkdownV2 because only three characters +// ever need escaping and the tags are written here rather than by the agent, so +// a marker left open halfway through a streamed answer comes out as literal text +// instead of a refused message. +func telegramHtml(text string) string { + out := &strings.Builder{} + lines := strings.Split(text, "\n") + + for index := 0; index < len(lines); index++ { + line := lines[index] + + if language, ok := fenceStart(line); ok { + body := []string{} + for index++; index < len(lines) && !isFence(lines[index]); index++ { + body = append(body, lines[index]) + } + writeCodeBlock(out, language, strings.Join(body, "\n")) + continue + } + + if isQuote(line) { + quoted := []string{} + for ; index < len(lines) && isQuote(lines[index]); index++ { + quoted = append(quoted, inlineHtml(trimQuote(lines[index]))) + } + index-- + out.WriteString("<blockquote>" + strings.Join(quoted, "\n") + "</blockquote>\n") + continue + } + + out.WriteString(blockLine(line) + "\n") + } + return strings.TrimRight(out.String(), "\n") +} + +func fenceStart(line string) (string, bool) { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "```") { + return "", false + } + return strings.TrimSpace(strings.TrimPrefix(trimmed, "```")), true +} + +func isFence(line string) bool { + return strings.HasPrefix(strings.TrimSpace(line), "```") +} + +func writeCodeBlock(out *strings.Builder, language, body string) { + tagged := isPlainWord(language) + out.WriteString("<pre>") + if tagged { + out.WriteString(`<code class="language-` + language + `">`) + } + out.WriteString(escapeHtml(body)) + if tagged { + out.WriteString("</code>") + } + out.WriteString("</pre>\n") +} + +func isQuote(line string) bool { + return strings.HasPrefix(strings.TrimSpace(line), ">") +} + +func trimQuote(line string) string { + return strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(line), ">"), " ") +} + +// blockLine renders the one-line shapes: a heading becomes bold, a list marker +// becomes a bullet, and a rule becomes a line somebody can actually see. +func blockLine(line string) string { + if trimmed := strings.TrimSpace(line); isRule(trimmed) { + return "──────────" + } + + indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] + rest := line[len(indent):] + + if hashes := len(rest) - len(strings.TrimLeft(rest, "#")); hashes > 0 && hashes <= 6 && strings.HasPrefix(rest[hashes:], " ") { + return indent + "<b>" + inlineHtml(strings.TrimSpace(rest[hashes:])) + "</b>" + } + + for _, marker := range []string{"- ", "* ", "+ "} { + if strings.HasPrefix(rest, marker) { + return indent + "• " + inlineHtml(rest[len(marker):]) + } + } + return indent + inlineHtml(rest) +} + +func isRule(line string) bool { + if len(line) < 3 { + return false + } + return strings.Trim(line, "-") == "" || strings.Trim(line, "*") == "" || strings.Trim(line, "_") == "" +} + +// inlineHtml walks one line and turns the markers it recognises into tags. A +// marker with nothing closing it is written out as the character it is. +func inlineHtml(text string) string { + out := &strings.Builder{} + runes := []rune(text) + + for index := 0; index < len(runes); { + switch { + case runes[index] == '`': + width := runLength(runes, index, '`') + if end := findRun(runes, index+width, '`', width); end >= 0 { + out.WriteString("<code>" + escapeHtml(strings.Trim(string(runes[index+width:end]), " ")) + "</code>") + index = end + width + continue + } + + case hasMarker(runes, index, "**"), hasMarker(runes, index, "__"): + marker := string(runes[index : index+2]) + if end := findMarker(runes, index+2, marker); end >= 0 { + out.WriteString("<b>" + inlineHtml(string(runes[index+2:end])) + "</b>") + index = end + 2 + continue + } + + case hasMarker(runes, index, "~~"): + if end := findMarker(runes, index+2, "~~"); end >= 0 { + out.WriteString("<s>" + inlineHtml(string(runes[index+2:end])) + "</s>") + index = end + 2 + continue + } + + case opensEmphasis(runes, index): + if end := findEmphasis(runes, index+1, runes[index]); end >= 0 { + out.WriteString("<i>" + inlineHtml(string(runes[index+1:end])) + "</i>") + index = end + 1 + continue + } + + case runes[index] == '[': + if label, url, width := linkAt(runes, index); width > 0 { + out.WriteString(`<a href="` + escapeHtml(url) + `">` + inlineHtml(label) + "</a>") + index += width + continue + } + } + + out.WriteString(escapeHtml(string(runes[index]))) + index++ + } + return out.String() +} + +func hasMarker(runes []rune, index int, marker string) bool { + return index+len(marker) <= len(runes) && string(runes[index:index+len(marker)]) == marker +} + +func findMarker(runes []rune, from int, marker string) int { + for index := from; index+len(marker) <= len(runes); index++ { + if hasMarker(runes, index, marker) && index > from { + return index + } + } + return -1 +} + +func runLength(runes []rune, index int, char rune) int { + width := 0 + for index+width < len(runes) && runes[index+width] == char { + width++ + } + return width +} + +func findRun(runes []rune, from int, char rune, width int) int { + for index := from; index < len(runes); index++ { + if runes[index] == char && runLength(runes, index, char) == width { + return index + } + } + return -1 +} + +// opensEmphasis keeps a single marker from opening on a word it is part of, so +// snake_case names and a lone asterisk stay as they were written. +func opensEmphasis(runes []rune, index int) bool { + char := runes[index] + if char != '*' && char != '_' { + return false + } + if index+1 >= len(runes) || isSpace(runes[index+1]) { + return false + } + if char == '_' && index > 0 && isWord(runes[index-1]) { + return false + } + return true +} + +func findEmphasis(runes []rune, from int, char rune) int { + for index := from; index < len(runes); index++ { + if runes[index] != char || isSpace(runes[index-1]) { + continue + } + if char == '_' && index+1 < len(runes) && isWord(runes[index+1]) { + continue + } + return index + } + return -1 +} + +// linkAt reads a [label](url) starting at index and reports how wide it was. +func linkAt(runes []rune, index int) (string, string, int) { + label := -1 + for scan := index + 1; scan < len(runes); scan++ { + if runes[scan] == ']' { + label = scan + break + } + } + if label < 0 || label+1 >= len(runes) || runes[label+1] != '(' { + return "", "", 0 + } + + end := -1 + for scan := label + 2; scan < len(runes); scan++ { + if isSpace(runes[scan]) { + return "", "", 0 + } + if runes[scan] == ')' { + end = scan + break + } + } + if end < 0 { + return "", "", 0 + } + + url := string(runes[label+2 : end]) + if !isSafeUrl(url) { + return "", "", 0 + } + return string(runes[index+1 : label]), url, end - index + 1 +} + +// isSafeUrl keeps a link to the schemes a chat should follow, so a javascript: +// href never reaches somebody's client. +func isSafeUrl(url string) bool { + lowered := strings.ToLower(url) + for _, scheme := range []string{"http://", "https://", "mailto:", "tg://"} { + if strings.HasPrefix(lowered, scheme) { + return true + } + } + return false +} + +func isPlainWord(text string) bool { + for _, char := range text { + if !isWord(char) && char != '-' && char != '+' && char != '#' { + return false + } + } + return text != "" +} + +func isWord(char rune) bool { + return char == '_' || char >= '0' && char <= '9' || char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char > 127 +} + +func isSpace(char rune) bool { + return char == ' ' || char == '\t' || char == '\n' +} + +func escapeHtml(text string) string { + return strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """).Replace(text) +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
