feat: added flag use_markdown_v2, corrected config, updated
documentation
This commit is contained in:
parent
dd9fb95b2a
commit
1b189f382e
14 changed files with 377 additions and 195 deletions
11
README.fr.md
11
README.fr.md
|
|
@ -238,7 +238,8 @@ picoclaw onboard
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "VOTRE_TOKEN_BOT",
|
"token": "VOTRE_TOKEN_BOT",
|
||||||
"allow_from": ["VOTRE_USER_ID"]
|
"allow_from": ["VOTRE_USER_ID"],
|
||||||
|
"use_markdown_v2": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
@ -307,7 +308,8 @@ Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "VOTRE_TOKEN_BOT",
|
"token": "VOTRE_TOKEN_BOT",
|
||||||
"allow_from": ["VOTRE_USER_ID"]
|
"allow_from": ["VOTRE_USER_ID"],
|
||||||
|
"use_markdown_v2": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -350,7 +352,7 @@ picoclaw gateway
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "VOTRE_TOKEN_BOT",
|
"token": "VOTRE_TOKEN_BOT",
|
||||||
"allow_from": ["VOTRE_USER_ID"]
|
"*.md": ["VOTRE_USER_ID"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -866,7 +868,8 @@ picoclaw agent -m "Bonjour, comment ça va ?"
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "123456:ABC...",
|
"token": "123456:ABC...",
|
||||||
"allow_from": ["123456789"]
|
"allow_from": ["123456789"],
|
||||||
|
"use_markdown_v2": false
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,8 @@ picoclaw onboard
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"use_markdown_v2": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
@ -276,7 +277,8 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"]
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
|
"use_markdown_v2": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -819,7 +821,8 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "123456:ABC...",
|
"token": "123456:ABC...",
|
||||||
"allow_from": ["123456789"]
|
"allow_from": ["123456789"],
|
||||||
|
"use_markdown_v2": false
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
|
||||||
|
|
@ -324,13 +324,15 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"]
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
|
"use_markdown_v2": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> Get your user ID from `@userinfobot` on Telegram.
|
> Get your user ID from `@userinfobot` on Telegram.
|
||||||
|
> Set `use_markdown_v2` to true could improve message formatting.
|
||||||
|
|
||||||
**3. Run**
|
**3. Run**
|
||||||
|
|
||||||
|
|
@ -1140,7 +1142,8 @@ picoclaw agent -m "Hello"
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "123456:ABC...",
|
"token": "123456:ABC...",
|
||||||
"allow_from": ["123456789"]
|
"allow_from": ["123456789"],
|
||||||
|
"use_markdown_v2": false
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,8 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"]
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
|
"use_markdown_v2": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,8 @@ picoclaw onboard
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"use_markdown_v2": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -275,7 +276,8 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom.
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allow_from": ["YOUR_USER_ID"]
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
|
"use_markdown_v2": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -834,7 +836,8 @@ picoclaw agent -m "Xin chào"
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "123456:ABC...",
|
"token": "123456:ABC...",
|
||||||
"allow_from": ["123456789"]
|
"allow_from": ["123456789"],
|
||||||
|
"use_markdown_v2": false
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
|
||||||
|
|
@ -713,7 +713,8 @@ picoclaw agent -m "你好"
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "123456:ABC...",
|
"token": "123456:ABC...",
|
||||||
"allow_from": ["123456789"]
|
"allow_from": ["123456789"],
|
||||||
|
"use_markdown_v2": false
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,8 @@
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||||
"proxy": "",
|
"proxy": "",
|
||||||
"allow_from": [
|
"allow_from": ["YOUR_USER_ID"],
|
||||||
"YOUR_USER_ID"
|
"use_markdown_v2": false,
|
||||||
],
|
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
|
|
|
||||||
160
pkg/channels/telegram/parse_markdown_to_md_v2.go
Normal file
160
pkg/channels/telegram/parse_markdown_to_md_v2.go
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// markdownToTelegramMarkdownV2 takes a standardized markdown string and
|
||||||
|
// strictly escapes or transforms it to fit Telegram's MarkdownV2 requirements.
|
||||||
|
// https://core.telegram.org/bots/api#formatting-options
|
||||||
|
func markdownToTelegramMarkdownV2(text string) string {
|
||||||
|
// replace Heading to bolding
|
||||||
|
text = reHeading.ReplaceAllString(text, "*$1*")
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
runes := []rune(text)
|
||||||
|
length := len(runes)
|
||||||
|
|
||||||
|
// List of characters that must be escaped in standard text contexts
|
||||||
|
needsNormalEscape := func(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < length {
|
||||||
|
// 1. Check for Pre-formatted Code Block (```...```)
|
||||||
|
if i+2 < length && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
|
||||||
|
result.WriteString("```")
|
||||||
|
i += 3
|
||||||
|
// Find closing ```
|
||||||
|
for i < length {
|
||||||
|
if i+2 < length && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
|
||||||
|
result.WriteString("```")
|
||||||
|
i += 3
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Inside code blocks, escape `\` and `\`
|
||||||
|
if runes[i] == '\\' || runes[i] == '`' {
|
||||||
|
result.WriteRune('\\')
|
||||||
|
}
|
||||||
|
result.WriteRune(runes[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for Inline Code (`...`)
|
||||||
|
if runes[i] == '`' {
|
||||||
|
result.WriteRune('`')
|
||||||
|
i++
|
||||||
|
for i < length {
|
||||||
|
if runes[i] == '`' {
|
||||||
|
result.WriteRune('`')
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if runes[i] == '\\' || runes[i] == '`' {
|
||||||
|
result.WriteRune('\\')
|
||||||
|
}
|
||||||
|
result.WriteRune(runes[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Link or Custom Emoji definition: URL part (...)
|
||||||
|
// We detect this by checking if the previous non-space character closed a bracket ']',
|
||||||
|
// and we are currently on '('. To keep logic linear, we handle it as we traverse.
|
||||||
|
// NOTE: A true deep-parser would link `[` to `](...)`. For safety, whenever we see `(`,
|
||||||
|
// if it looks like a URL part, we escape it via URL rules. Let's do a basic lookbehind.
|
||||||
|
if runes[i] == '(' && i > 0 && runes[i-1] == ']' {
|
||||||
|
result.WriteRune('(')
|
||||||
|
i++
|
||||||
|
for i < length {
|
||||||
|
if runes[i] == ')' {
|
||||||
|
// Unescaped closing bracket ends the URL
|
||||||
|
result.WriteRune(')')
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// In URL part, escape `\` and `)`
|
||||||
|
if runes[i] == '\\' || runes[i] == ')' {
|
||||||
|
result.WriteRune('\\')
|
||||||
|
}
|
||||||
|
result.WriteRune(runes[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Handle blockquotes starts
|
||||||
|
if runes[i] == '>' && (i == 0 || runes[i-1] == '\n') {
|
||||||
|
result.WriteRune('>')
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Handle expandable block quotation starts
|
||||||
|
if runes[i] == '>' && runes[i-1] == '*' && runes[i-2] == '*' && (i == 0 || runes[i-3] == '\n') {
|
||||||
|
result.WriteRune(runes[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Handle standard Markdown Entities Boundaries
|
||||||
|
// If they are part of valid markdown boundaries, we write them as-is.
|
||||||
|
// We trust the syntax rules: * _ ~ || [ ]
|
||||||
|
// (Assuming the text is a valid markdown, we don't escape these if formatting is intended)
|
||||||
|
|
||||||
|
// Note on Ambiguity (__ vs _):
|
||||||
|
// Telegram parses `__` from left to right greedily.
|
||||||
|
if i+1 < length && runes[i] == '_' && runes[i+1] == '_' {
|
||||||
|
result.WriteString("__")
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if i+1 < length && runes[i] == '|' && runes[i+1] == '|' {
|
||||||
|
result.WriteString("||")
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard single-char boundaries
|
||||||
|
if runes[i] == '*' || runes[i] == '_' || runes[i] == '~' || runes[i] == '[' || runes[i] == ']' {
|
||||||
|
result.WriteRune(runes[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom emoji boundary check `![`
|
||||||
|
if i+1 < length && runes[i] == '!' && runes[i+1] == '[' {
|
||||||
|
result.WriteString("![")
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Handle plain text characters
|
||||||
|
// Escape remaining special characters if they aren't forming intended valid markup
|
||||||
|
if needsNormalEscape(runes[i]) {
|
||||||
|
// Check if it's already escaped; if an escape character exists, consume it legitimately
|
||||||
|
if runes[i] == '\\' && i+1 < length && needsNormalEscape(runes[i+1]) {
|
||||||
|
// Keep the backslash and the escaped char as is, avoiding double escaping
|
||||||
|
result.WriteRune('\\')
|
||||||
|
result.WriteRune(runes[i+1])
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-escape the character
|
||||||
|
result.WriteRune('\\')
|
||||||
|
}
|
||||||
|
|
||||||
|
result.WriteRune(runes[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,6 @@ package telegram
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"fmt"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
@ -13,29 +12,44 @@ var md2AllFormats string
|
||||||
|
|
||||||
func Test_markdownToTelegramMarkdownV2(t *testing.T) {
|
func Test_markdownToTelegramMarkdownV2(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
name string
|
||||||
input string
|
input string
|
||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
name: "heading -> bolding",
|
||||||
input: `## HeadingH2 #`,
|
input: `## HeadingH2 #`,
|
||||||
expected: "*HeadingH2 \\#*",
|
expected: "*HeadingH2 \\#*",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: "strikethrough",
|
||||||
input: "~strikethroughMD~",
|
input: "~strikethroughMD~",
|
||||||
expected: "~strikethroughMD~",
|
expected: "~strikethroughMD~",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: "inline URL",
|
||||||
input: "[inline URL](http://www.example.com/)",
|
input: "[inline URL](http://www.example.com/)",
|
||||||
expected: "[inline URL](http://www.example.com/)",
|
expected: "[inline URL](http://www.example.com/)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
name: "all telegram formats",
|
||||||
input: md2AllFormats,
|
input: md2AllFormats,
|
||||||
expected: md2AllFormats,
|
expected: md2AllFormats,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one letter",
|
||||||
|
input: "o",
|
||||||
|
expected: "o",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(fmt.Sprintf("formating %s -> %s", tc.input, tc.expected), func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
actual := markdownToTelegramMarkdownV2(tc.input)
|
actual := markdownToTelegramMarkdownV2(tc.input)
|
||||||
|
|
||||||
require.EqualValues(t, tc.expected, actual)
|
require.EqualValues(t, tc.expected, actual)
|
||||||
111
pkg/channels/telegram/parser_markdown_to_html.go
Normal file
111
pkg/channels/telegram/parser_markdown_to_html.go
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func markdownToTelegramHTML(text string) string {
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
codeBlocks := extractCodeBlocks(text)
|
||||||
|
text = codeBlocks.text
|
||||||
|
|
||||||
|
inlineCodes := extractInlineCodes(text)
|
||||||
|
text = inlineCodes.text
|
||||||
|
|
||||||
|
text = reHeading.ReplaceAllString(text, "$1")
|
||||||
|
|
||||||
|
text = reBlockquote.ReplaceAllString(text, "$1")
|
||||||
|
|
||||||
|
text = escapeHTML(text)
|
||||||
|
|
||||||
|
text = reLink.ReplaceAllString(text, `<a href="$2">$1</a>`)
|
||||||
|
|
||||||
|
text = reBoldStar.ReplaceAllString(text, "<b>$1</b>")
|
||||||
|
|
||||||
|
text = reBoldUnder.ReplaceAllString(text, "<b>$1</b>")
|
||||||
|
|
||||||
|
text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
|
||||||
|
match := reItalic.FindStringSubmatch(s)
|
||||||
|
if len(match) < 2 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "<i>" + match[1] + "</i>"
|
||||||
|
})
|
||||||
|
|
||||||
|
text = reStrike.ReplaceAllString(text, "<s>$1</s>")
|
||||||
|
|
||||||
|
text = reListItem.ReplaceAllString(text, "• ")
|
||||||
|
|
||||||
|
for i, code := range inlineCodes.codes {
|
||||||
|
escaped := escapeHTML(code)
|
||||||
|
text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("<code>%s</code>", escaped))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, code := range codeBlocks.codes {
|
||||||
|
escaped := escapeHTML(code)
|
||||||
|
text = strings.ReplaceAll(
|
||||||
|
text,
|
||||||
|
fmt.Sprintf("\x00CB%d\x00", i),
|
||||||
|
fmt.Sprintf("<pre><code>%s</code></pre>", escaped),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
type codeBlockMatch struct {
|
||||||
|
text string
|
||||||
|
codes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCodeBlocks(text string) codeBlockMatch {
|
||||||
|
matches := reCodeBlock.FindAllStringSubmatch(text, -1)
|
||||||
|
|
||||||
|
codes := make([]string, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
codes = append(codes, match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
|
||||||
|
placeholder := fmt.Sprintf("\x00CB%d\x00", i)
|
||||||
|
i++
|
||||||
|
return placeholder
|
||||||
|
})
|
||||||
|
|
||||||
|
return codeBlockMatch{text: text, codes: codes}
|
||||||
|
}
|
||||||
|
|
||||||
|
type inlineCodeMatch struct {
|
||||||
|
text string
|
||||||
|
codes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractInlineCodes(text string) inlineCodeMatch {
|
||||||
|
matches := reInlineCode.FindAllStringSubmatch(text, -1)
|
||||||
|
|
||||||
|
codes := make([]string, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
codes = append(codes, match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
|
||||||
|
placeholder := fmt.Sprintf("\x00IC%d\x00", i)
|
||||||
|
i++
|
||||||
|
return placeholder
|
||||||
|
})
|
||||||
|
|
||||||
|
return inlineCodeMatch{text: text, codes: codes}
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeHTML(text string) string {
|
||||||
|
text = strings.ReplaceAll(text, "&", "&")
|
||||||
|
text = strings.ReplaceAll(text, "<", "<")
|
||||||
|
text = strings.ReplaceAll(text, ">", ">")
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,18 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var reHeading = regexp.MustCompile(`(?m)^#{1,6}\s+([^\n]+)`)
|
var (
|
||||||
|
reHeading = regexp.MustCompile(`(?m)^#{1,6}\s+([^\n]+)`)
|
||||||
|
reBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
|
||||||
|
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||||
|
reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
|
||||||
|
reBoldUnder = regexp.MustCompile(`__(.+?)__`)
|
||||||
|
reItalic = regexp.MustCompile(`_([^_]+)_`)
|
||||||
|
reStrike = regexp.MustCompile(`~~(.+?)~~`)
|
||||||
|
reListItem = regexp.MustCompile(`^[-*]\s+`)
|
||||||
|
reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
|
||||||
|
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||||
|
)
|
||||||
|
|
||||||
type TelegramChannel struct {
|
type TelegramChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
|
|
@ -162,15 +173,13 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
markdownV2Content := markdownToTelegramMarkdownV2(msg.Content)
|
content := c.parseContent(msg.Content)
|
||||||
|
|
||||||
tgMsg := tu.Message(tu.ID(chatID), markdownV2Content).
|
tgMsg := tu.Message(tu.ID(chatID), content).
|
||||||
WithParseMode(telego.ModeMarkdownV2)
|
WithParseMode(telego.ModeMarkdownV2)
|
||||||
|
|
||||||
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
||||||
logger.ErrorCF("telegram", "MarkdownV2 parse failed, falling back to plain text", map[string]any{
|
logParseFailed(c.config.Channels.Telegram.UseMarkdownV2, err)
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
if _, err = c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), msg.Content)); err != nil {
|
if _, err = c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), msg.Content)); err != nil {
|
||||||
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
|
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
|
|
@ -219,10 +228,15 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
md2Content := markdownToTelegramMarkdownV2(content)
|
parsedContent := c.parseContent(content)
|
||||||
editMsg := tu.EditMessageText(tu.ID(cid), mid, md2Content).
|
editMsg := tu.EditMessageText(tu.ID(cid), mid, parsedContent).
|
||||||
WithParseMode(telego.ModeMarkdownV2)
|
WithParseMode(telego.ModeMarkdownV2)
|
||||||
_, err = c.bot.EditMessageText(ctx, editMsg)
|
_, err = c.bot.EditMessageText(ctx, editMsg)
|
||||||
|
if err != nil {
|
||||||
|
logParseFailed(c.config.Channels.Telegram.UseMarkdownV2, err)
|
||||||
|
_, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content))
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -535,169 +549,33 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string)
|
||||||
return c.downloadFileWithInfo(file, ext)
|
return c.downloadFileWithInfo(file, ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) parseContent(text string) string {
|
||||||
|
if c.config.Channels.Telegram.UseMarkdownV2 {
|
||||||
|
return markdownToTelegramMarkdownV2(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return markdownToTelegramHTML(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logParseFailed(useMarkdownV2 bool, err error) {
|
||||||
|
parsingName := "HTML"
|
||||||
|
if useMarkdownV2 {
|
||||||
|
parsingName = "MarkdownV2"
|
||||||
|
}
|
||||||
|
logger.ErrorCF("telegram",
|
||||||
|
fmt.Sprintf("%s parse failed, falling back to plain text", parsingName),
|
||||||
|
map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func parseChatID(chatIDStr string) (int64, error) {
|
func parseChatID(chatIDStr string) (int64, error) {
|
||||||
var id int64
|
var id int64
|
||||||
_, err := fmt.Sscanf(chatIDStr, "%d", &id)
|
_, err := fmt.Sscanf(chatIDStr, "%d", &id)
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// markdownToTelegramMarkdownV2 takes a standardized markdown string and
|
|
||||||
// strictly escapes or transforms it to fit Telegram's MarkdownV2 requirements.
|
|
||||||
// https://core.telegram.org/bots/api#formatting-options
|
|
||||||
func markdownToTelegramMarkdownV2(text string) string {
|
|
||||||
// replace Heading to bolding
|
|
||||||
text = reHeading.ReplaceAllString(text, "*$1*")
|
|
||||||
|
|
||||||
var result strings.Builder
|
|
||||||
runes := []rune(text)
|
|
||||||
length := len(runes)
|
|
||||||
|
|
||||||
// List of characters that must be escaped in standard text contexts
|
|
||||||
needsNormalEscape := func(r rune) bool {
|
|
||||||
switch r {
|
|
||||||
case '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!':
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
i := 0
|
|
||||||
for i < length {
|
|
||||||
// 1. Check for Pre-formatted Code Block (```...```)
|
|
||||||
if i+2 < length && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
|
|
||||||
result.WriteString("```")
|
|
||||||
i += 3
|
|
||||||
// Find closing ```
|
|
||||||
for i < length {
|
|
||||||
if i+2 < length && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
|
|
||||||
result.WriteString("```")
|
|
||||||
i += 3
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// Inside code blocks, escape `\` and `\`
|
|
||||||
if runes[i] == '\\' || runes[i] == '`' {
|
|
||||||
result.WriteRune('\\')
|
|
||||||
}
|
|
||||||
result.WriteRune(runes[i])
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check for Inline Code (`...`)
|
|
||||||
if runes[i] == '`' {
|
|
||||||
result.WriteRune('`')
|
|
||||||
i++
|
|
||||||
for i < length {
|
|
||||||
if runes[i] == '`' {
|
|
||||||
result.WriteRune('`')
|
|
||||||
i++
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if runes[i] == '\\' || runes[i] == '`' {
|
|
||||||
result.WriteRune('\\')
|
|
||||||
}
|
|
||||||
result.WriteRune(runes[i])
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Link or Custom Emoji definition: URL part (...)
|
|
||||||
// We detect this by checking if the previous non-space character closed a bracket ']',
|
|
||||||
// and we are currently on '('. To keep logic linear, we handle it as we traverse.
|
|
||||||
// NOTE: A true deep-parser would link `[` to `](...)`. For safety, whenever we see `(`,
|
|
||||||
// if it looks like a URL part, we escape it via URL rules. Let's do a basic lookbehind.
|
|
||||||
if runes[i] == '(' && i > 0 && runes[i-1] == ']' {
|
|
||||||
result.WriteRune('(')
|
|
||||||
i++
|
|
||||||
for i < length {
|
|
||||||
if runes[i] == ')' {
|
|
||||||
// Unescaped closing bracket ends the URL
|
|
||||||
result.WriteRune(')')
|
|
||||||
i++
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// In URL part, escape `\` and `)`
|
|
||||||
if runes[i] == '\\' || runes[i] == ')' {
|
|
||||||
result.WriteRune('\\')
|
|
||||||
}
|
|
||||||
result.WriteRune(runes[i])
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Handle blockquotes starts
|
|
||||||
if runes[i] == '>' && (i == 0 || runes[i-1] == '\n') {
|
|
||||||
result.WriteRune('>')
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Handle expandable block quotation starts
|
|
||||||
if runes[i] == '>' && runes[i-1] == '*' && runes[i-2] == '*' && (i == 0 || runes[i-3] == '\n') {
|
|
||||||
result.WriteRune(runes[i])
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Handle standard Markdown Entities Boundaries
|
|
||||||
// If they are part of valid markdown boundaries, we write them as-is.
|
|
||||||
// We trust the syntax rules: * _ ~ || [ ]
|
|
||||||
// (Assuming the text is a valid markdown, we don't escape these if formatting is intended)
|
|
||||||
|
|
||||||
// Note on Ambiguity (__ vs _):
|
|
||||||
// Telegram parses `__` from left to right greedily.
|
|
||||||
if i+1 < length && runes[i] == '_' && runes[i+1] == '_' {
|
|
||||||
result.WriteString("__")
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if i+1 < length && runes[i] == '|' && runes[i+1] == '|' {
|
|
||||||
result.WriteString("||")
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard single-char boundaries
|
|
||||||
if runes[i] == '*' || runes[i] == '_' || runes[i] == '~' || runes[i] == '[' || runes[i] == ']' {
|
|
||||||
result.WriteRune(runes[i])
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom emoji boundary check `![`
|
|
||||||
if i+1 < length && runes[i] == '!' && runes[i+1] == '[' {
|
|
||||||
result.WriteString("![")
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Handle plain text characters
|
|
||||||
// Escape remaining special characters if they aren't forming intended valid markup
|
|
||||||
if needsNormalEscape(runes[i]) {
|
|
||||||
// Check if it's already escaped; if an escape character exists, consume it legitimately
|
|
||||||
if runes[i] == '\\' && i+1 < length && needsNormalEscape(runes[i+1]) {
|
|
||||||
// Keep the backslash and the escaped char as is, avoiding double escaping
|
|
||||||
result.WriteRune('\\')
|
|
||||||
result.WriteRune(runes[i+1])
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-escape the character
|
|
||||||
result.WriteRune('\\')
|
|
||||||
}
|
|
||||||
|
|
||||||
result.WriteRune(runes[i])
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// isBotMentioned checks if the bot is mentioned in the message via entities.
|
// isBotMentioned checks if the bot is mentioned in the message via entities.
|
||||||
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
||||||
botUsername := c.bot.Username()
|
botUsername := c.bot.Username()
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,7 @@ type TelegramConfig struct {
|
||||||
Typing TypingConfig `json:"typing,omitempty"`
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
|
||||||
|
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeishuConfig struct {
|
type FeishuConfig struct {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ func DefaultConfig() *Config {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Text: "Thinking... 💭",
|
Text: "Thinking... 💭",
|
||||||
},
|
},
|
||||||
|
UseMarkdownV2: false,
|
||||||
},
|
},
|
||||||
Feishu: FeishuConfig{
|
Feishu: FeishuConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
|
|
||||||
|
|
@ -132,11 +132,12 @@ type OpenClawChannels struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenClawTelegramConfig struct {
|
type OpenClawTelegramConfig struct {
|
||||||
BotToken *string `json:"botToken"`
|
BotToken *string `json:"botToken"`
|
||||||
AllowFrom []string `json:"allowFrom"`
|
AllowFrom []string `json:"allowFrom"`
|
||||||
GroupPolicy *string `json:"groupPolicy"`
|
GroupPolicy *string `json:"groupPolicy"`
|
||||||
DmPolicy *string `json:"dmPolicy"`
|
DmPolicy *string `json:"dmPolicy"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
|
UseMarkdownV2 *bool `json:"useMarkdownV2"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenClawDiscordConfig struct {
|
type OpenClawDiscordConfig struct {
|
||||||
|
|
@ -637,10 +638,11 @@ type WhatsAppConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type TelegramConfig struct {
|
type TelegramConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Proxy string `json:"proxy"`
|
Proxy string `json:"proxy"`
|
||||||
AllowFrom []string `json:"allow_from"`
|
AllowFrom []string `json:"allow_from"`
|
||||||
|
UseMarkdownV2 bool `json:"use_markdown_v2"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeishuConfig struct {
|
type FeishuConfig struct {
|
||||||
|
|
@ -758,9 +760,11 @@ func (c *OpenClawConfig) convertChannels(warnings *[]string) ChannelsConfig {
|
||||||
|
|
||||||
if c.Channels.Telegram != nil {
|
if c.Channels.Telegram != nil {
|
||||||
enabled := c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled
|
enabled := c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled
|
||||||
|
useMarkdownV2 := c.Channels.Telegram.UseMarkdownV2 != nil && *c.Channels.Telegram.UseMarkdownV2
|
||||||
channels.Telegram = TelegramConfig{
|
channels.Telegram = TelegramConfig{
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
AllowFrom: c.Channels.Telegram.AllowFrom,
|
AllowFrom: c.Channels.Telegram.AllowFrom,
|
||||||
|
UseMarkdownV2: useMarkdownV2,
|
||||||
}
|
}
|
||||||
if c.Channels.Telegram.BotToken != nil {
|
if c.Channels.Telegram.BotToken != nil {
|
||||||
channels.Telegram.Token = *c.Channels.Telegram.BotToken
|
channels.Telegram.Token = *c.Channels.Telegram.BotToken
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue