Merge pull request #116 from hobbyistlabs-coder/refactor-telegram-channel-12550617070564727877
Refactor Telegram Channel into Modular Files
This commit is contained in:
commit
2d96fbbe29
7 changed files with 756 additions and 693 deletions
193
pkg/channels/telegram/events.go
Normal file
193
pkg/channels/telegram/events.go
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
|
||||||
|
"jane/pkg/bus"
|
||||||
|
"jane/pkg/channels"
|
||||||
|
"jane/pkg/identity"
|
||||||
|
"jane/pkg/logger"
|
||||||
|
"jane/pkg/media"
|
||||||
|
"jane/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
|
||||||
|
if message == nil {
|
||||||
|
return fmt.Errorf("message is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := message.From
|
||||||
|
if user == nil {
|
||||||
|
return fmt.Errorf("message sender (user) is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
platformID := fmt.Sprintf("%d", user.ID)
|
||||||
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "telegram",
|
||||||
|
PlatformID: platformID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("telegram", platformID),
|
||||||
|
Username: user.Username,
|
||||||
|
DisplayName: user.FirstName,
|
||||||
|
}
|
||||||
|
|
||||||
|
// check allowlist to avoid downloading attachments for rejected users
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
|
||||||
|
"user_id": platformID,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID := message.Chat.ID
|
||||||
|
c.chatIDs[platformID] = chatID
|
||||||
|
|
||||||
|
content := ""
|
||||||
|
mediaPaths := []string{}
|
||||||
|
|
||||||
|
chatIDStr := fmt.Sprintf("%d", chatID)
|
||||||
|
messageIDStr := fmt.Sprintf("%d", message.MessageID)
|
||||||
|
scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr)
|
||||||
|
|
||||||
|
// Helper to register a local file with the media store
|
||||||
|
storeMedia := func(localPath, filename string) string {
|
||||||
|
if store := c.GetMediaStore(); store != nil {
|
||||||
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
|
Filename: filename,
|
||||||
|
Source: "telegram",
|
||||||
|
}, scope)
|
||||||
|
if err == nil {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return localPath // fallback: use raw path
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.Text != "" {
|
||||||
|
content += message.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.Caption != "" {
|
||||||
|
if content != "" {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
content += message.Caption
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(message.Photo) > 0 {
|
||||||
|
photo := message.Photo[len(message.Photo)-1]
|
||||||
|
photoPath := c.downloadPhoto(ctx, photo.FileID)
|
||||||
|
if photoPath != "" {
|
||||||
|
mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg"))
|
||||||
|
if content != "" {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
content += "[image: photo]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.Voice != nil {
|
||||||
|
voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
|
||||||
|
if voicePath != "" {
|
||||||
|
mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg"))
|
||||||
|
|
||||||
|
if content != "" {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
content += "[voice]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.Audio != nil {
|
||||||
|
audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
|
||||||
|
if audioPath != "" {
|
||||||
|
mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3"))
|
||||||
|
if content != "" {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
content += "[audio]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.Document != nil {
|
||||||
|
docPath := c.downloadFile(ctx, message.Document.FileID, "")
|
||||||
|
if docPath != "" {
|
||||||
|
mediaPaths = append(mediaPaths, storeMedia(docPath, "document"))
|
||||||
|
if content != "" {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
content += "[file]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if content == "" {
|
||||||
|
content = "[empty message]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// In group chats, apply unified group trigger filtering
|
||||||
|
if message.Chat.Type != "private" {
|
||||||
|
isMentioned := c.isBotMentioned(message)
|
||||||
|
if isMentioned {
|
||||||
|
content = c.stripBotMention(content)
|
||||||
|
}
|
||||||
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||||
|
if !respond {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content = cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
// For forum topics, embed the thread ID as "chatID/threadID" so replies
|
||||||
|
// route to the correct topic and each topic gets its own session.
|
||||||
|
// Only forum groups (IsForum) are handled; regular group reply threads
|
||||||
|
// must share one session per group.
|
||||||
|
compositeChatID := fmt.Sprintf("%d", chatID)
|
||||||
|
threadID := message.MessageThreadID
|
||||||
|
if message.Chat.IsForum && threadID != 0 {
|
||||||
|
compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("telegram", "Received message", map[string]any{
|
||||||
|
"sender_id": sender.CanonicalID,
|
||||||
|
"chat_id": compositeChatID,
|
||||||
|
"thread_id": threadID,
|
||||||
|
"preview": utils.Truncate(content, 50),
|
||||||
|
})
|
||||||
|
|
||||||
|
peerKind := "direct"
|
||||||
|
peerID := fmt.Sprintf("%d", user.ID)
|
||||||
|
if message.Chat.Type != "private" {
|
||||||
|
peerKind = "group"
|
||||||
|
peerID = compositeChatID
|
||||||
|
}
|
||||||
|
|
||||||
|
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
||||||
|
messageID := fmt.Sprintf("%d", message.MessageID)
|
||||||
|
|
||||||
|
metadata := map[string]string{
|
||||||
|
"user_id": fmt.Sprintf("%d", user.ID),
|
||||||
|
"username": user.Username,
|
||||||
|
"first_name": user.FirstName,
|
||||||
|
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set parent_peer metadata for per-topic agent binding.
|
||||||
|
if message.Chat.IsForum && threadID != 0 {
|
||||||
|
metadata["parent_peer_kind"] = "topic"
|
||||||
|
metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(c.ctx,
|
||||||
|
peer,
|
||||||
|
messageID,
|
||||||
|
platformID,
|
||||||
|
compositeChatID,
|
||||||
|
content,
|
||||||
|
mediaPaths,
|
||||||
|
metadata,
|
||||||
|
sender,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
138
pkg/channels/telegram/media.go
Normal file
138
pkg/channels/telegram/media.go
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
tu "github.com/mymmrac/telego/telegoutil"
|
||||||
|
|
||||||
|
"jane/pkg/bus"
|
||||||
|
"jane/pkg/channels"
|
||||||
|
"jane/pkg/logger"
|
||||||
|
"jane/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
|
func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range msg.Parts {
|
||||||
|
localPath, err := store.Resolve(part.Ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("telegram", "Failed to resolve media ref", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("telegram", "Failed to open media file", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch part.Type {
|
||||||
|
case "image":
|
||||||
|
params := &telego.SendPhotoParams{
|
||||||
|
ChatID: tu.ID(chatID),
|
||||||
|
MessageThreadID: threadID,
|
||||||
|
Photo: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
|
}
|
||||||
|
_, err = c.bot.SendPhoto(ctx, params)
|
||||||
|
case "audio":
|
||||||
|
params := &telego.SendAudioParams{
|
||||||
|
ChatID: tu.ID(chatID),
|
||||||
|
MessageThreadID: threadID,
|
||||||
|
Audio: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
|
}
|
||||||
|
_, err = c.bot.SendAudio(ctx, params)
|
||||||
|
case "video":
|
||||||
|
params := &telego.SendVideoParams{
|
||||||
|
ChatID: tu.ID(chatID),
|
||||||
|
MessageThreadID: threadID,
|
||||||
|
Video: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
|
}
|
||||||
|
_, err = c.bot.SendVideo(ctx, params)
|
||||||
|
default: // "file" or unknown types
|
||||||
|
params := &telego.SendDocumentParams{
|
||||||
|
ChatID: tu.ID(chatID),
|
||||||
|
MessageThreadID: threadID,
|
||||||
|
Document: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
|
}
|
||||||
|
_, err = c.bot.SendDocument(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("telegram", "Failed to send media", map[string]any{
|
||||||
|
"type": part.Type,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
|
||||||
|
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.downloadFileWithInfo(file, ".jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) string {
|
||||||
|
if file.FilePath == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
url := c.bot.FileDownloadURL(file.FilePath)
|
||||||
|
logger.DebugCF("telegram", "File URL", map[string]any{"url": url})
|
||||||
|
|
||||||
|
// Use FilePath as filename for better identification
|
||||||
|
filename := file.FilePath + ext
|
||||||
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||||
|
LoggerPrefix: "telegram",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
|
||||||
|
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("telegram", "Failed to get file", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.downloadFileWithInfo(file, ext)
|
||||||
|
}
|
||||||
138
pkg/channels/telegram/messages.go
Normal file
138
pkg/channels/telegram/messages.go
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
tu "github.com/mymmrac/telego/telegoutil"
|
||||||
|
|
||||||
|
"jane/pkg/bus"
|
||||||
|
"jane/pkg/channels"
|
||||||
|
"jane/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
|
||||||
|
// so msg.Content is guaranteed to be within that limit. We still need to
|
||||||
|
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
|
||||||
|
replyToID := msg.ReplyToMessageID
|
||||||
|
queue := []string{msg.Content}
|
||||||
|
for len(queue) > 0 {
|
||||||
|
chunk := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
|
||||||
|
htmlContent := markdownToTelegramHTML(chunk)
|
||||||
|
|
||||||
|
if len([]rune(htmlContent)) > 4096 {
|
||||||
|
ratio := float64(len([]rune(chunk))) / float64(len([]rune(htmlContent)))
|
||||||
|
smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin
|
||||||
|
if smallerLen < 100 {
|
||||||
|
smallerLen = 100
|
||||||
|
}
|
||||||
|
// Push sub-chunks back to the front of the queue for
|
||||||
|
// re-validation instead of sending them blindly.
|
||||||
|
subChunks := channels.SplitMessage(chunk, smallerLen)
|
||||||
|
queue = append(subChunks, queue...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk, replyToID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Only the first chunk should be a reply; subsequent chunks are normal messages.
|
||||||
|
replyToID = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendHTMLChunk sends a single HTML message, falling back to the original
|
||||||
|
// markdown as plain text on parse failure so users never see raw HTML tags.
|
||||||
|
func (c *TelegramChannel) sendHTMLChunk(
|
||||||
|
ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string, replyToID string,
|
||||||
|
) error {
|
||||||
|
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
||||||
|
tgMsg.ParseMode = telego.ModeHTML
|
||||||
|
tgMsg.MessageThreadID = threadID
|
||||||
|
|
||||||
|
if replyToID != "" {
|
||||||
|
if mid, parseErr := strconv.Atoi(replyToID); parseErr == nil {
|
||||||
|
tgMsg.ReplyParameters = &telego.ReplyParameters{
|
||||||
|
MessageID: mid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
|
||||||
|
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
tgMsg.Text = mdFallback
|
||||||
|
tgMsg.ParseMode = ""
|
||||||
|
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
||||||
|
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMessage implements channels.MessageEditor.
|
||||||
|
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||||
|
cid, _, err := parseTelegramChatID(chatID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mid, err := strconv.Atoi(messageID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
htmlContent := markdownToTelegramHTML(content)
|
||||||
|
editMsg := tu.EditMessageText(tu.ID(cid), mid, htmlContent)
|
||||||
|
editMsg.ParseMode = telego.ModeHTML
|
||||||
|
_, err = c.bot.EditMessageText(ctx, editMsg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPlaceholder implements channels.PlaceholderCapable.
|
||||||
|
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
|
||||||
|
// edited to the actual response via EditMessage (channels.MessageEditor).
|
||||||
|
func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||||
|
phCfg := c.config.Channels.Telegram.Placeholder
|
||||||
|
if !phCfg.Enabled {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
text := phCfg.Text
|
||||||
|
if text == "" {
|
||||||
|
text = "Thinking... 💭"
|
||||||
|
}
|
||||||
|
|
||||||
|
cid, threadID, err := parseTelegramChatID(chatID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
phMsg := tu.Message(tu.ID(cid), text)
|
||||||
|
phMsg.MessageThreadID = threadID
|
||||||
|
pMsg, err := c.bot.SendMessage(ctx, phMsg)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d", pMsg.MessageID), nil
|
||||||
|
}
|
||||||
125
pkg/channels/telegram/parsing.go
Normal file
125
pkg/channels/telegram/parsing.go
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
|
||||||
|
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("`([^`]+)`")
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -6,36 +6,16 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
th "github.com/mymmrac/telego/telegohandler"
|
th "github.com/mymmrac/telego/telegohandler"
|
||||||
tu "github.com/mymmrac/telego/telegoutil"
|
|
||||||
|
|
||||||
"jane/pkg/bus"
|
"jane/pkg/bus"
|
||||||
"jane/pkg/channels"
|
"jane/pkg/channels"
|
||||||
"jane/pkg/commands"
|
"jane/pkg/commands"
|
||||||
"jane/pkg/config"
|
"jane/pkg/config"
|
||||||
"jane/pkg/identity"
|
|
||||||
"jane/pkg/logger"
|
"jane/pkg/logger"
|
||||||
"jane/pkg/media"
|
|
||||||
"jane/pkg/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
|
|
||||||
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 {
|
||||||
|
|
@ -163,676 +143,3 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|
||||||
if !c.IsRunning() {
|
|
||||||
return channels.ErrNotRunning
|
|
||||||
}
|
|
||||||
|
|
||||||
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg.Content == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
|
|
||||||
// so msg.Content is guaranteed to be within that limit. We still need to
|
|
||||||
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
|
|
||||||
replyToID := msg.ReplyToMessageID
|
|
||||||
queue := []string{msg.Content}
|
|
||||||
for len(queue) > 0 {
|
|
||||||
chunk := queue[0]
|
|
||||||
queue = queue[1:]
|
|
||||||
|
|
||||||
htmlContent := markdownToTelegramHTML(chunk)
|
|
||||||
|
|
||||||
if len([]rune(htmlContent)) > 4096 {
|
|
||||||
ratio := float64(len([]rune(chunk))) / float64(len([]rune(htmlContent)))
|
|
||||||
smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin
|
|
||||||
if smallerLen < 100 {
|
|
||||||
smallerLen = 100
|
|
||||||
}
|
|
||||||
// Push sub-chunks back to the front of the queue for
|
|
||||||
// re-validation instead of sending them blindly.
|
|
||||||
subChunks := channels.SplitMessage(chunk, smallerLen)
|
|
||||||
queue = append(subChunks, queue...)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk, replyToID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Only the first chunk should be a reply; subsequent chunks are normal messages.
|
|
||||||
replyToID = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendHTMLChunk sends a single HTML message, falling back to the original
|
|
||||||
// markdown as plain text on parse failure so users never see raw HTML tags.
|
|
||||||
func (c *TelegramChannel) sendHTMLChunk(
|
|
||||||
ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string, replyToID string,
|
|
||||||
) error {
|
|
||||||
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
|
||||||
tgMsg.ParseMode = telego.ModeHTML
|
|
||||||
tgMsg.MessageThreadID = threadID
|
|
||||||
|
|
||||||
if replyToID != "" {
|
|
||||||
if mid, parseErr := strconv.Atoi(replyToID); parseErr == nil {
|
|
||||||
tgMsg.ReplyParameters = &telego.ReplyParameters{
|
|
||||||
MessageID: mid,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
|
|
||||||
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
tgMsg.Text = mdFallback
|
|
||||||
tgMsg.ParseMode = ""
|
|
||||||
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
|
||||||
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTyping implements channels.TypingCapable.
|
|
||||||
// It sends ChatAction(typing) immediately and then repeats every 4 seconds
|
|
||||||
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
|
|
||||||
// The returned stop function is idempotent and cancels the goroutine.
|
|
||||||
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
|
||||||
cid, threadID, err := parseTelegramChatID(chatID)
|
|
||||||
if err != nil {
|
|
||||||
return func() {}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
|
||||||
action.MessageThreadID = threadID
|
|
||||||
|
|
||||||
// Send the first typing action immediately
|
|
||||||
_ = c.bot.SendChatAction(ctx, action)
|
|
||||||
|
|
||||||
typingCtx, cancel := context.WithCancel(ctx)
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(4 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-typingCtx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
|
||||||
a.MessageThreadID = threadID
|
|
||||||
_ = c.bot.SendChatAction(typingCtx, a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return cancel, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EditMessage implements channels.MessageEditor.
|
|
||||||
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
|
||||||
cid, _, err := parseTelegramChatID(chatID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
mid, err := strconv.Atoi(messageID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
htmlContent := markdownToTelegramHTML(content)
|
|
||||||
editMsg := tu.EditMessageText(tu.ID(cid), mid, htmlContent)
|
|
||||||
editMsg.ParseMode = telego.ModeHTML
|
|
||||||
_, err = c.bot.EditMessageText(ctx, editMsg)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendPlaceholder implements channels.PlaceholderCapable.
|
|
||||||
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
|
|
||||||
// edited to the actual response via EditMessage (channels.MessageEditor).
|
|
||||||
func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
|
||||||
phCfg := c.config.Channels.Telegram.Placeholder
|
|
||||||
if !phCfg.Enabled {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
text := phCfg.Text
|
|
||||||
if text == "" {
|
|
||||||
text = "Thinking... 💭"
|
|
||||||
}
|
|
||||||
|
|
||||||
cid, threadID, err := parseTelegramChatID(chatID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
phMsg := tu.Message(tu.ID(cid), text)
|
|
||||||
phMsg.MessageThreadID = threadID
|
|
||||||
pMsg, err := c.bot.SendMessage(ctx, phMsg)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%d", pMsg.MessageID), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendMedia implements the channels.MediaSender interface.
|
|
||||||
func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
|
||||||
if !c.IsRunning() {
|
|
||||||
return channels.ErrNotRunning
|
|
||||||
}
|
|
||||||
|
|
||||||
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
store := c.GetMediaStore()
|
|
||||||
if store == nil {
|
|
||||||
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, part := range msg.Parts {
|
|
||||||
localPath, err := store.Resolve(part.Ref)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("telegram", "Failed to resolve media ref", map[string]any{
|
|
||||||
"ref": part.Ref,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(localPath)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("telegram", "Failed to open media file", map[string]any{
|
|
||||||
"path": localPath,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch part.Type {
|
|
||||||
case "image":
|
|
||||||
params := &telego.SendPhotoParams{
|
|
||||||
ChatID: tu.ID(chatID),
|
|
||||||
MessageThreadID: threadID,
|
|
||||||
Photo: telego.InputFile{File: file},
|
|
||||||
Caption: part.Caption,
|
|
||||||
}
|
|
||||||
_, err = c.bot.SendPhoto(ctx, params)
|
|
||||||
case "audio":
|
|
||||||
params := &telego.SendAudioParams{
|
|
||||||
ChatID: tu.ID(chatID),
|
|
||||||
MessageThreadID: threadID,
|
|
||||||
Audio: telego.InputFile{File: file},
|
|
||||||
Caption: part.Caption,
|
|
||||||
}
|
|
||||||
_, err = c.bot.SendAudio(ctx, params)
|
|
||||||
case "video":
|
|
||||||
params := &telego.SendVideoParams{
|
|
||||||
ChatID: tu.ID(chatID),
|
|
||||||
MessageThreadID: threadID,
|
|
||||||
Video: telego.InputFile{File: file},
|
|
||||||
Caption: part.Caption,
|
|
||||||
}
|
|
||||||
_, err = c.bot.SendVideo(ctx, params)
|
|
||||||
default: // "file" or unknown types
|
|
||||||
params := &telego.SendDocumentParams{
|
|
||||||
ChatID: tu.ID(chatID),
|
|
||||||
MessageThreadID: threadID,
|
|
||||||
Document: telego.InputFile{File: file},
|
|
||||||
Caption: part.Caption,
|
|
||||||
}
|
|
||||||
_, err = c.bot.SendDocument(ctx, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
file.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("telegram", "Failed to send media", map[string]any{
|
|
||||||
"type": part.Type,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
|
|
||||||
if message == nil {
|
|
||||||
return fmt.Errorf("message is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
user := message.From
|
|
||||||
if user == nil {
|
|
||||||
return fmt.Errorf("message sender (user) is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
platformID := fmt.Sprintf("%d", user.ID)
|
|
||||||
sender := bus.SenderInfo{
|
|
||||||
Platform: "telegram",
|
|
||||||
PlatformID: platformID,
|
|
||||||
CanonicalID: identity.BuildCanonicalID("telegram", platformID),
|
|
||||||
Username: user.Username,
|
|
||||||
DisplayName: user.FirstName,
|
|
||||||
}
|
|
||||||
|
|
||||||
// check allowlist to avoid downloading attachments for rejected users
|
|
||||||
if !c.IsAllowedSender(sender) {
|
|
||||||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
|
|
||||||
"user_id": platformID,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
chatID := message.Chat.ID
|
|
||||||
c.chatIDs[platformID] = chatID
|
|
||||||
|
|
||||||
content := ""
|
|
||||||
mediaPaths := []string{}
|
|
||||||
|
|
||||||
chatIDStr := fmt.Sprintf("%d", chatID)
|
|
||||||
messageIDStr := fmt.Sprintf("%d", message.MessageID)
|
|
||||||
scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr)
|
|
||||||
|
|
||||||
// Helper to register a local file with the media store
|
|
||||||
storeMedia := func(localPath, filename string) string {
|
|
||||||
if store := c.GetMediaStore(); store != nil {
|
|
||||||
ref, err := store.Store(localPath, media.MediaMeta{
|
|
||||||
Filename: filename,
|
|
||||||
Source: "telegram",
|
|
||||||
}, scope)
|
|
||||||
if err == nil {
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return localPath // fallback: use raw path
|
|
||||||
}
|
|
||||||
|
|
||||||
if message.Text != "" {
|
|
||||||
content += message.Text
|
|
||||||
}
|
|
||||||
|
|
||||||
if message.Caption != "" {
|
|
||||||
if content != "" {
|
|
||||||
content += "\n"
|
|
||||||
}
|
|
||||||
content += message.Caption
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(message.Photo) > 0 {
|
|
||||||
photo := message.Photo[len(message.Photo)-1]
|
|
||||||
photoPath := c.downloadPhoto(ctx, photo.FileID)
|
|
||||||
if photoPath != "" {
|
|
||||||
mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg"))
|
|
||||||
if content != "" {
|
|
||||||
content += "\n"
|
|
||||||
}
|
|
||||||
content += "[image: photo]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if message.Voice != nil {
|
|
||||||
voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
|
|
||||||
if voicePath != "" {
|
|
||||||
mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg"))
|
|
||||||
|
|
||||||
if content != "" {
|
|
||||||
content += "\n"
|
|
||||||
}
|
|
||||||
content += "[voice]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if message.Audio != nil {
|
|
||||||
audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
|
|
||||||
if audioPath != "" {
|
|
||||||
mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3"))
|
|
||||||
if content != "" {
|
|
||||||
content += "\n"
|
|
||||||
}
|
|
||||||
content += "[audio]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if message.Document != nil {
|
|
||||||
docPath := c.downloadFile(ctx, message.Document.FileID, "")
|
|
||||||
if docPath != "" {
|
|
||||||
mediaPaths = append(mediaPaths, storeMedia(docPath, "document"))
|
|
||||||
if content != "" {
|
|
||||||
content += "\n"
|
|
||||||
}
|
|
||||||
content += "[file]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if content == "" {
|
|
||||||
content = "[empty message]"
|
|
||||||
}
|
|
||||||
|
|
||||||
// In group chats, apply unified group trigger filtering
|
|
||||||
if message.Chat.Type != "private" {
|
|
||||||
isMentioned := c.isBotMentioned(message)
|
|
||||||
if isMentioned {
|
|
||||||
content = c.stripBotMention(content)
|
|
||||||
}
|
|
||||||
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
|
||||||
if !respond {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content = cleaned
|
|
||||||
}
|
|
||||||
|
|
||||||
// For forum topics, embed the thread ID as "chatID/threadID" so replies
|
|
||||||
// route to the correct topic and each topic gets its own session.
|
|
||||||
// Only forum groups (IsForum) are handled; regular group reply threads
|
|
||||||
// must share one session per group.
|
|
||||||
compositeChatID := fmt.Sprintf("%d", chatID)
|
|
||||||
threadID := message.MessageThreadID
|
|
||||||
if message.Chat.IsForum && threadID != 0 {
|
|
||||||
compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.DebugCF("telegram", "Received message", map[string]any{
|
|
||||||
"sender_id": sender.CanonicalID,
|
|
||||||
"chat_id": compositeChatID,
|
|
||||||
"thread_id": threadID,
|
|
||||||
"preview": utils.Truncate(content, 50),
|
|
||||||
})
|
|
||||||
|
|
||||||
peerKind := "direct"
|
|
||||||
peerID := fmt.Sprintf("%d", user.ID)
|
|
||||||
if message.Chat.Type != "private" {
|
|
||||||
peerKind = "group"
|
|
||||||
peerID = compositeChatID
|
|
||||||
}
|
|
||||||
|
|
||||||
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
|
||||||
messageID := fmt.Sprintf("%d", message.MessageID)
|
|
||||||
|
|
||||||
metadata := map[string]string{
|
|
||||||
"user_id": fmt.Sprintf("%d", user.ID),
|
|
||||||
"username": user.Username,
|
|
||||||
"first_name": user.FirstName,
|
|
||||||
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set parent_peer metadata for per-topic agent binding.
|
|
||||||
if message.Chat.IsForum && threadID != 0 {
|
|
||||||
metadata["parent_peer_kind"] = "topic"
|
|
||||||
metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HandleMessage(c.ctx,
|
|
||||||
peer,
|
|
||||||
messageID,
|
|
||||||
platformID,
|
|
||||||
compositeChatID,
|
|
||||||
content,
|
|
||||||
mediaPaths,
|
|
||||||
metadata,
|
|
||||||
sender,
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
|
|
||||||
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.downloadFileWithInfo(file, ".jpg")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) string {
|
|
||||||
if file.FilePath == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
url := c.bot.FileDownloadURL(file.FilePath)
|
|
||||||
logger.DebugCF("telegram", "File URL", map[string]any{"url": url})
|
|
||||||
|
|
||||||
// Use FilePath as filename for better identification
|
|
||||||
filename := file.FilePath + ext
|
|
||||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
|
||||||
LoggerPrefix: "telegram",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
|
|
||||||
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("telegram", "Failed to get file", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.downloadFileWithInfo(file, ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTelegramChatID splits "chatID/threadID" into its components.
|
|
||||||
// Returns threadID=0 when no "/" is present (non-forum messages).
|
|
||||||
func parseTelegramChatID(chatID string) (int64, int, error) {
|
|
||||||
idx := strings.Index(chatID, "/")
|
|
||||||
if idx == -1 {
|
|
||||||
cid, err := strconv.ParseInt(chatID, 10, 64)
|
|
||||||
return cid, 0, err
|
|
||||||
}
|
|
||||||
cid, err := strconv.ParseInt(chatID[:idx], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
tid, err := strconv.Atoi(chatID[idx+1:])
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err)
|
|
||||||
}
|
|
||||||
return cid, tid, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// isBotMentioned checks if the bot is mentioned in the message via entities.
|
|
||||||
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
|
||||||
text, entities := telegramEntityTextAndList(message)
|
|
||||||
if text == "" || len(entities) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
botUsername := ""
|
|
||||||
if c.bot != nil {
|
|
||||||
botUsername = c.bot.Username()
|
|
||||||
}
|
|
||||||
runes := []rune(text)
|
|
||||||
|
|
||||||
for _, entity := range entities {
|
|
||||||
entityText, ok := telegramEntityText(runes, entity)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch entity.Type {
|
|
||||||
case telego.EntityTypeMention:
|
|
||||||
if botUsername != "" && strings.EqualFold(entityText, "@"+botUsername) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case telego.EntityTypeTextMention:
|
|
||||||
if botUsername != "" && entity.User != nil && strings.EqualFold(entity.User.Username, botUsername) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case telego.EntityTypeBotCommand:
|
|
||||||
if isBotCommandEntityForThisBot(entityText, botUsername) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func telegramEntityTextAndList(message *telego.Message) (string, []telego.MessageEntity) {
|
|
||||||
if message.Text != "" {
|
|
||||||
return message.Text, message.Entities
|
|
||||||
}
|
|
||||||
return message.Caption, message.CaptionEntities
|
|
||||||
}
|
|
||||||
|
|
||||||
func telegramEntityText(runes []rune, entity telego.MessageEntity) (string, bool) {
|
|
||||||
if entity.Offset < 0 || entity.Length <= 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
end := entity.Offset + entity.Length
|
|
||||||
if entity.Offset >= len(runes) || end > len(runes) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return string(runes[entity.Offset:end]), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func isBotCommandEntityForThisBot(entityText, botUsername string) bool {
|
|
||||||
if !strings.HasPrefix(entityText, "/") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
command := strings.TrimPrefix(entityText, "/")
|
|
||||||
if command == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
at := strings.IndexRune(command, '@')
|
|
||||||
if at == -1 {
|
|
||||||
// A bare /command delivered to this bot is intended for this bot.
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
mentionUsername := command[at+1:]
|
|
||||||
if mentionUsername == "" || botUsername == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.EqualFold(mentionUsername, botUsername)
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripBotMention removes the @bot mention from the content.
|
|
||||||
func (c *TelegramChannel) stripBotMention(content string) string {
|
|
||||||
botUsername := c.bot.Username()
|
|
||||||
if botUsername == "" {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
// Case-insensitive replacement
|
|
||||||
re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(botUsername))
|
|
||||||
content = re.ReplaceAllString(content, "")
|
|
||||||
return strings.TrimSpace(content)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
44
pkg/channels/telegram/typing.go
Normal file
44
pkg/channels/telegram/typing.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
tu "github.com/mymmrac/telego/telegoutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartTyping implements channels.TypingCapable.
|
||||||
|
// It sends ChatAction(typing) immediately and then repeats every 4 seconds
|
||||||
|
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
|
||||||
|
// The returned stop function is idempotent and cancels the goroutine.
|
||||||
|
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
|
cid, threadID, err := parseTelegramChatID(chatID)
|
||||||
|
if err != nil {
|
||||||
|
return func() {}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
||||||
|
action.MessageThreadID = threadID
|
||||||
|
|
||||||
|
// Send the first typing action immediately
|
||||||
|
_ = c.bot.SendChatAction(ctx, action)
|
||||||
|
|
||||||
|
typingCtx, cancel := context.WithCancel(ctx)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(4 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-typingCtx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
||||||
|
a.MessageThreadID = threadID
|
||||||
|
_ = c.bot.SendChatAction(typingCtx, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return cancel, nil
|
||||||
|
}
|
||||||
118
pkg/channels/telegram/utils.go
Normal file
118
pkg/channels/telegram/utils.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseTelegramChatID splits "chatID/threadID" into its components.
|
||||||
|
// Returns threadID=0 when no "/" is present (non-forum messages).
|
||||||
|
func parseTelegramChatID(chatID string) (int64, int, error) {
|
||||||
|
idx := strings.Index(chatID, "/")
|
||||||
|
if idx == -1 {
|
||||||
|
cid, err := strconv.ParseInt(chatID, 10, 64)
|
||||||
|
return cid, 0, err
|
||||||
|
}
|
||||||
|
cid, err := strconv.ParseInt(chatID[:idx], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
tid, err := strconv.Atoi(chatID[idx+1:])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err)
|
||||||
|
}
|
||||||
|
return cid, tid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBotMentioned checks if the bot is mentioned in the message via entities.
|
||||||
|
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
||||||
|
text, entities := telegramEntityTextAndList(message)
|
||||||
|
if text == "" || len(entities) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
botUsername := ""
|
||||||
|
if c.bot != nil {
|
||||||
|
botUsername = c.bot.Username()
|
||||||
|
}
|
||||||
|
runes := []rune(text)
|
||||||
|
|
||||||
|
for _, entity := range entities {
|
||||||
|
entityText, ok := telegramEntityText(runes, entity)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch entity.Type {
|
||||||
|
case telego.EntityTypeMention:
|
||||||
|
if botUsername != "" && strings.EqualFold(entityText, "@"+botUsername) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case telego.EntityTypeTextMention:
|
||||||
|
if botUsername != "" && entity.User != nil && strings.EqualFold(entity.User.Username, botUsername) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case telego.EntityTypeBotCommand:
|
||||||
|
if isBotCommandEntityForThisBot(entityText, botUsername) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func telegramEntityTextAndList(message *telego.Message) (string, []telego.MessageEntity) {
|
||||||
|
if message.Text != "" {
|
||||||
|
return message.Text, message.Entities
|
||||||
|
}
|
||||||
|
return message.Caption, message.CaptionEntities
|
||||||
|
}
|
||||||
|
|
||||||
|
func telegramEntityText(runes []rune, entity telego.MessageEntity) (string, bool) {
|
||||||
|
if entity.Offset < 0 || entity.Length <= 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
end := entity.Offset + entity.Length
|
||||||
|
if entity.Offset >= len(runes) || end > len(runes) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return string(runes[entity.Offset:end]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBotCommandEntityForThisBot(entityText, botUsername string) bool {
|
||||||
|
if !strings.HasPrefix(entityText, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
command := strings.TrimPrefix(entityText, "/")
|
||||||
|
if command == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
at := strings.IndexRune(command, '@')
|
||||||
|
if at == -1 {
|
||||||
|
// A bare /command delivered to this bot is intended for this bot.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mentionUsername := command[at+1:]
|
||||||
|
if mentionUsername == "" || botUsername == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(mentionUsername, botUsername)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripBotMention removes the @bot mention from the content.
|
||||||
|
func (c *TelegramChannel) stripBotMention(content string) string {
|
||||||
|
botUsername := c.bot.Username()
|
||||||
|
if botUsername == "" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
// Case-insensitive replacement
|
||||||
|
re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(botUsername))
|
||||||
|
content = re.ReplaceAllString(content, "")
|
||||||
|
return strings.TrimSpace(content)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue