refactor(telegram): reduce code duplication and improve readability

- Extract parseTelegramTarget helper to consolidate duplicated @username
  vs numeric chat ID parsing from 6+ locations (builder functions,
  sendTelegramTextReturnMessage, sendTelegramAttachmentImpl)
- Extract Config.baseURL() to eliminate duplicate base URL resolution
  between apiEndpoint() and fileEndpoint()
- Refactor stream.go Push method: extract resetStreamState(),
  deliverFinalText(), and per-event-type sub-methods (pushDelta,
  pushFinal, pushToolCallStart, pushAttachment, pushPhaseEnd,
  pushError), reducing the 200-line switch-case to a clean dispatcher
- Use pushFinal's existing getBot() instead of duplicating parseConfig +
  getOrCreateBot
- Replace sort.SliceStable with slices.SortStableFunc + cmp.Compare
- Replace strings.Index + manual slicing with strings.Cut in
  decodeDataURLBytes, ResolveAttachment, and parseTelegramUserInput
This commit is contained in:
Menci
2026-03-08 20:35:07 +08:00
committed by 晨苒
parent 32f42a201b
commit 09cdb8c87f
4 changed files with 267 additions and 269 deletions
+9 -8
View File
@@ -15,22 +15,23 @@ type Config struct {
APIBaseURL string // Reverse proxy base URL for regions where Telegram is blocked (e.g. China mainland)
}
// apiEndpoint returns the Sprintf-formatted API endpoint derived from the base URL.
func (c Config) apiEndpoint() string {
// baseURL returns the effective base URL with trailing slashes removed.
func (c Config) baseURL() string {
base := c.APIBaseURL
if base == "" {
base = defaultAPIBaseURL
}
return strings.TrimRight(base, "/") + "/bot%s/%s"
return strings.TrimRight(base, "/")
}
// apiEndpoint returns the Sprintf-formatted API endpoint derived from the base URL.
func (c Config) apiEndpoint() string {
return c.baseURL() + "/bot%s/%s"
}
// fileEndpoint returns the Sprintf-formatted file download endpoint derived from the base URL.
func (c Config) fileEndpoint() string {
base := c.APIBaseURL
if base == "" {
base = defaultAPIBaseURL
}
return strings.TrimRight(base, "/") + "/file/bot%s/%s"
return c.baseURL() + "/file/bot%s/%s"
}
// UserConfig holds the identifiers used to target a Telegram user or group.
@@ -222,11 +222,9 @@ func parseTelegramUserInput(input string) (chatID, userID int64) {
if input == "" {
return 0, 0
}
if idx := strings.Index(input, ":"); idx >= 0 {
left := strings.TrimSpace(input[:idx])
right := strings.TrimSpace(input[idx+1:])
cid, err1 := strconv.ParseInt(left, 10, 64)
uid, err2 := strconv.ParseInt(right, 10, 64)
if left, right, ok := strings.Cut(input, ":"); ok {
cid, err1 := strconv.ParseInt(strings.TrimSpace(left), 10, 64)
uid, err2 := strconv.ParseInt(strings.TrimSpace(right), 10, 64)
if err1 == nil && err2 == nil && cid != 0 && uid != 0 {
return cid, uid
}
+189 -182
View File
@@ -265,6 +265,188 @@ func (s *telegramOutboundStream) sendPermanentMessage(ctx context.Context, text
return sendTelegramText(bot, s.target, text, replyTo, parseMode)
}
// resetStreamState clears the streaming message state so a fresh message will
// be created on the next delta. Must be called without holding s.mu.
func (s *telegramOutboundStream) resetStreamState() {
s.mu.Lock()
s.streamMsgID = 0
if !s.isPrivateChat {
s.streamChatID = 0
}
s.lastEdited = ""
s.lastEditedAt = time.Time{}
s.buf.Reset()
s.mu.Unlock()
}
// deliverFinalText sends or edits the final text depending on chat mode.
func (s *telegramOutboundStream) deliverFinalText(ctx context.Context, text, parseMode string) error {
if s.isPrivateChat {
return s.sendPermanentMessage(ctx, text, parseMode)
}
if err := s.ensureStreamMessage(ctx, text); err != nil {
return err
}
return s.editStreamMessageFinal(ctx, text)
}
func (s *telegramOutboundStream) pushToolCallStart(ctx context.Context) error {
s.mu.Lock()
bufText := strings.TrimSpace(s.buf.String())
hasMsg := s.streamMsgID != 0
s.mu.Unlock()
if s.isPrivateChat {
// In draft mode, send buffered text as a permanent message before tool execution.
if bufText != "" {
if err := s.sendPermanentMessage(ctx, bufText, ""); err != nil {
if s.adapter != nil && s.adapter.logger != nil {
s.adapter.logger.Warn("telegram: draft permanent message failed", slog.Any("error", err))
}
}
}
} else if hasMsg && bufText != "" {
_ = s.editStreamMessageFinal(ctx, bufText)
}
s.resetStreamState()
return nil
}
func (s *telegramOutboundStream) pushAttachment(ctx context.Context, event channel.StreamEvent) error {
if len(event.Attachments) == 0 {
return nil
}
bot, replyTo, err := s.getBotAndReply(ctx)
if err != nil {
return err
}
for _, att := range event.Attachments {
if sendErr := sendTelegramAttachmentWithAssets(ctx, bot, s.target, att, "", replyTo, "", s.adapter.assets); sendErr != nil {
if s.adapter != nil && s.adapter.logger != nil {
s.adapter.logger.Warn("telegram: stream attachment send failed",
slog.String("config_id", s.cfg.ID),
slog.String("type", string(att.Type)),
slog.Any("error", sendErr),
)
}
}
}
return nil
}
func (s *telegramOutboundStream) pushPhaseEnd(ctx context.Context, event channel.StreamEvent) error {
if event.Phase != channel.StreamPhaseText {
return nil
}
// In draft mode, skip phase-end finalization; StreamEventFinal sends the
// permanent formatted message.
if s.isPrivateChat {
return nil
}
s.mu.Lock()
finalText := strings.TrimSpace(s.buf.String())
s.mu.Unlock()
if finalText != "" {
if err := s.ensureStreamMessage(ctx, finalText); err != nil {
return err
}
return s.editStreamMessageFinal(ctx, finalText)
}
return nil
}
func (s *telegramOutboundStream) pushDelta(ctx context.Context, event channel.StreamEvent) error {
if event.Delta == "" || event.Phase == channel.StreamPhaseReasoning {
return nil
}
s.mu.Lock()
s.buf.WriteString(event.Delta)
content := s.buf.String()
s.mu.Unlock()
if s.isPrivateChat {
return s.sendDraft(ctx, content)
}
if err := s.ensureStreamMessage(ctx, content); err != nil {
return err
}
return s.editStreamMessage(ctx, content)
}
func (s *telegramOutboundStream) pushFinal(ctx context.Context, event channel.StreamEvent) error {
// In draft mode, read and reset buffer atomically to prevent duplicate
// permanent messages when multiple StreamEventFinal events fire
// (one per assistant output in multi-tool-call responses).
s.mu.Lock()
bufText := strings.TrimSpace(s.buf.String())
if s.isPrivateChat {
s.buf.Reset()
}
s.mu.Unlock()
if event.Final == nil || event.Final.Message.IsEmpty() {
if bufText != "" {
if err := s.deliverFinalText(ctx, bufText, ""); err != nil {
if s.adapter != nil && s.adapter.logger != nil {
s.adapter.logger.Warn("telegram: deliver buffered final text failed", slog.Any("error", err))
}
}
}
return nil
}
msg := event.Final.Message
finalText := bufText
if finalText == "" && !s.isPrivateChat {
finalText = strings.TrimSpace(msg.PlainText())
}
// Convert markdown to Telegram HTML for the final message.
formatted, pm := formatTelegramOutput(finalText, msg.Format)
if pm != "" {
s.mu.Lock()
s.parseMode = pm
s.mu.Unlock()
finalText = formatted
}
if err := s.deliverFinalText(ctx, finalText, s.parseMode); err != nil {
return err
}
if len(msg.Attachments) > 0 {
bot, err := s.getBot(ctx)
if err != nil {
return err
}
replyTo := parseReplyToMessageID(s.reply)
parseMode := resolveTelegramParseMode(msg.Format)
for i, att := range msg.Attachments {
to := replyTo
if i > 0 {
to = 0
}
if err := sendTelegramAttachmentWithAssets(ctx, bot, s.target, att, "", to, parseMode, s.adapter.assets); err != nil && s.adapter.logger != nil {
s.adapter.logger.Error("stream final attachment failed", slog.String("config_id", s.cfg.ID), slog.Any("error", err))
}
}
}
return nil
}
func (s *telegramOutboundStream) pushError(ctx context.Context, event channel.StreamEvent) error {
errText := strings.TrimSpace(event.Error)
if errText == "" {
return nil
}
display := "Error: " + errText
if s.isPrivateChat {
return s.sendPermanentMessage(ctx, display, "")
}
if err := s.ensureStreamMessage(ctx, display); err != nil {
return err
}
return s.editStreamMessage(ctx, display)
}
func (s *telegramOutboundStream) Push(ctx context.Context, event channel.StreamEvent) error {
if s == nil || s.adapter == nil {
return errors.New("telegram stream not configured")
@@ -278,196 +460,21 @@ func (s *telegramOutboundStream) Push(ctx context.Context, event channel.StreamE
default:
}
switch event.Type {
case channel.StreamEventStatus:
return nil
case channel.StreamEventToolCallStart:
s.mu.Lock()
bufText := strings.TrimSpace(s.buf.String())
hasMsg := s.streamMsgID != 0
s.mu.Unlock()
if s.isPrivateChat {
// In draft mode, send buffered text as a permanent message before tool execution.
if bufText != "" {
if err := s.sendPermanentMessage(ctx, bufText, ""); err != nil {
if s.adapter != nil && s.adapter.logger != nil {
s.adapter.logger.Warn("telegram: draft permanent message failed", slog.Any("error", err))
}
}
}
} else if hasMsg && bufText != "" {
_ = s.editStreamMessageFinal(ctx, bufText)
}
s.mu.Lock()
s.streamMsgID = 0
if !s.isPrivateChat {
s.streamChatID = 0
}
s.lastEdited = ""
s.lastEditedAt = time.Time{}
s.buf.Reset()
s.mu.Unlock()
return nil
return s.pushToolCallStart(ctx)
case channel.StreamEventToolCallEnd:
s.mu.Lock()
s.streamMsgID = 0
if !s.isPrivateChat {
s.streamChatID = 0
}
s.lastEdited = ""
s.lastEditedAt = time.Time{}
s.buf.Reset()
s.mu.Unlock()
s.resetStreamState()
return nil
case channel.StreamEventAttachment:
if len(event.Attachments) == 0 {
return nil
}
bot, replyTo, err := s.getBotAndReply(ctx)
if err != nil {
return err
}
for _, att := range event.Attachments {
if sendErr := sendTelegramAttachmentWithAssets(ctx, bot, s.target, att, "", replyTo, "", s.adapter.assets); sendErr != nil {
if s.adapter != nil && s.adapter.logger != nil {
s.adapter.logger.Warn("telegram: stream attachment send failed",
slog.String("config_id", s.cfg.ID),
slog.String("type", string(att.Type)),
slog.Any("error", sendErr),
)
}
}
}
return nil
case channel.StreamEventPhaseStart:
return nil
return s.pushAttachment(ctx, event)
case channel.StreamEventPhaseEnd:
if event.Phase == channel.StreamPhaseText {
// In draft mode, skip phase-end finalization; StreamEventFinal sends the
// permanent formatted message.
if s.isPrivateChat {
return nil
}
s.mu.Lock()
finalText := strings.TrimSpace(s.buf.String())
s.mu.Unlock()
if finalText != "" {
if err := s.ensureStreamMessage(ctx, finalText); err != nil {
return err
}
return s.editStreamMessageFinal(ctx, finalText)
}
}
return nil
case channel.StreamEventProcessingFailed, channel.StreamEventAgentStart, channel.StreamEventAgentEnd, channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted:
return nil
return s.pushPhaseEnd(ctx, event)
case channel.StreamEventDelta:
if event.Delta == "" || event.Phase == channel.StreamPhaseReasoning {
return nil
}
s.mu.Lock()
s.buf.WriteString(event.Delta)
content := s.buf.String()
s.mu.Unlock()
if s.isPrivateChat {
return s.sendDraft(ctx, content)
}
if err := s.ensureStreamMessage(ctx, content); err != nil {
return err
}
return s.editStreamMessage(ctx, content)
return s.pushDelta(ctx, event)
case channel.StreamEventFinal:
// In draft mode, read and reset buffer atomically to prevent duplicate
// permanent messages when multiple StreamEventFinal events fire
// (one per assistant output in multi-tool-call responses).
s.mu.Lock()
bufText := strings.TrimSpace(s.buf.String())
if s.isPrivateChat {
s.buf.Reset()
}
s.mu.Unlock()
if event.Final == nil || event.Final.Message.IsEmpty() {
if bufText != "" {
if s.isPrivateChat {
if err := s.sendPermanentMessage(ctx, bufText, ""); err != nil {
if s.adapter != nil && s.adapter.logger != nil {
s.adapter.logger.Warn("telegram: draft final permanent message failed", slog.Any("error", err))
}
}
} else {
if err := s.ensureStreamMessage(ctx, bufText); err != nil {
if s.adapter != nil && s.adapter.logger != nil {
s.adapter.logger.Warn("telegram: ensure stream message failed", slog.Any("error", err))
}
}
if err := s.editStreamMessageFinal(ctx, bufText); err != nil {
if s.adapter != nil && s.adapter.logger != nil {
s.adapter.logger.Warn("telegram: edit stream message failed", slog.Any("error", err))
}
}
}
}
return nil
}
msg := event.Final.Message
finalText := bufText
if finalText == "" && !s.isPrivateChat {
finalText = strings.TrimSpace(msg.PlainText())
}
// Convert markdown to Telegram HTML for the final message.
formatted, pm := formatTelegramOutput(finalText, msg.Format)
if pm != "" {
s.mu.Lock()
s.parseMode = pm
s.mu.Unlock()
finalText = formatted
}
if s.isPrivateChat {
if err := s.sendPermanentMessage(ctx, finalText, s.parseMode); err != nil {
return err
}
} else {
if err := s.ensureStreamMessage(ctx, finalText); err != nil {
return err
}
if err := s.editStreamMessageFinal(ctx, finalText); err != nil {
return err
}
}
if len(msg.Attachments) > 0 {
replyTo := parseReplyToMessageID(s.reply)
telegramCfg, err := parseConfig(s.cfg.Credentials)
if err != nil {
return err
}
bot, err := s.adapter.getOrCreateBot(telegramCfg, s.cfg.ID)
if err != nil {
return err
}
parseMode := resolveTelegramParseMode(msg.Format)
for i, att := range msg.Attachments {
to := replyTo
if i > 0 {
to = 0
}
if err := sendTelegramAttachmentWithAssets(ctx, bot, s.target, att, "", to, parseMode, s.adapter.assets); err != nil && s.adapter.logger != nil {
s.adapter.logger.Error("stream final attachment failed", slog.String("config_id", s.cfg.ID), slog.Any("error", err))
}
}
}
return nil
return s.pushFinal(ctx, event)
case channel.StreamEventError:
errText := strings.TrimSpace(event.Error)
if errText == "" {
return nil
}
display := "Error: " + errText
if s.isPrivateChat {
return s.sendPermanentMessage(ctx, display, "")
}
if err := s.ensureStreamMessage(ctx, display); err != nil {
return err
}
return s.editStreamMessage(ctx, display)
return s.pushError(ctx, event)
default:
return nil
}
+66 -74
View File
@@ -1,6 +1,7 @@
package telegram
import (
"cmp"
"context"
"encoding/base64"
"errors"
@@ -8,7 +9,7 @@ import (
"io"
"log/slog"
"net/http"
"sort"
"slices"
"strconv"
"strings"
"sync"
@@ -434,8 +435,8 @@ func (a *TelegramAdapter) buildTelegramMediaGroupInboundMessage(
if len(items) == 0 {
return channel.InboundMessage{}, false
}
sort.SliceStable(items, func(i, j int) bool {
return items[i].MessageID < items[j].MessageID
slices.SortStableFunc(items, func(a, b *tgbotapi.Message) int {
return cmp.Compare(a.MessageID, b.MessageID)
})
anchor := items[0]
text := ""
@@ -743,32 +744,25 @@ func sendTelegramTextReturnMessage(bot *tgbotapi.BotAPI, target string, text str
if sendTextForTest != nil {
return sendTextForTest(bot, target, text, replyTo, parseMode)
}
var sent tgbotapi.Message
if strings.HasPrefix(target, "@") {
message := tgbotapi.NewMessageToChannel(target, text)
message.ParseMode = parseMode
if replyTo > 0 {
message.ReplyToMessageID = replyTo
}
sent, err = bot.Send(message)
if err != nil {
return 0, 0, err
}
} else {
chatID, err = strconv.ParseInt(target, 10, 64)
if err != nil {
return 0, 0, errors.New("telegram target must be @username or chat_id")
}
message := tgbotapi.NewMessage(chatID, text)
message.ParseMode = parseMode
if replyTo > 0 {
message.ReplyToMessageID = replyTo
}
sent, err = bot.Send(message)
if err != nil {
return 0, 0, err
}
parsedChatID, channelUsername, parseErr := parseTelegramTarget(target)
if parseErr != nil {
return 0, 0, parseErr
}
var message tgbotapi.MessageConfig
if channelUsername != "" {
message = tgbotapi.NewMessageToChannel(channelUsername, text)
} else {
message = tgbotapi.NewMessage(parsedChatID, text)
}
message.ParseMode = parseMode
if replyTo > 0 {
message.ReplyToMessageID = replyTo
}
sent, err := bot.Send(message)
if err != nil {
return 0, 0, err
}
chatID = parsedChatID
if sent.Chat != nil {
chatID = sent.Chat.ID
}
@@ -875,17 +869,17 @@ func sendTelegramAttachmentImpl(ctx context.Context, bot *tgbotapi.BotAPI, targe
if err != nil {
return err
}
isChannel := strings.HasPrefix(target, "@")
chatID, channelUsername, targetErr := parseTelegramTarget(target)
if targetErr != nil {
return targetErr
}
isChannel := channelUsername != ""
switch att.Type {
case channel.AttachmentImage:
var photo tgbotapi.PhotoConfig
if isChannel {
photo = tgbotapi.NewPhotoToChannel(target, file)
photo = tgbotapi.NewPhotoToChannel(channelUsername, file)
} else {
chatID, err := strconv.ParseInt(target, 10, 64)
if err != nil {
return errors.New("telegram target must be @username or chat_id")
}
photo = tgbotapi.NewPhoto(chatID, file)
}
photo.Caption = caption
@@ -900,15 +894,11 @@ func sendTelegramAttachmentImpl(ctx context.Context, bot *tgbotapi.BotAPI, targe
if isChannel {
document = tgbotapi.DocumentConfig{
BaseFile: tgbotapi.BaseFile{
BaseChat: tgbotapi.BaseChat{ChannelUsername: target},
BaseChat: tgbotapi.BaseChat{ChannelUsername: channelUsername},
File: file,
},
}
} else {
chatID, err := strconv.ParseInt(target, 10, 64)
if err != nil {
return errors.New("telegram target must be @username or chat_id")
}
document = tgbotapi.NewDocument(chatID, file)
}
document.Caption = caption
@@ -1017,8 +1007,8 @@ func resolveTelegramFile(ctx context.Context, urlRef, keyRef, base64Ref, sourceP
func decodeDataURLBytes(dataURL string) ([]byte, error) {
value := dataURL
if idx := strings.Index(value, ","); idx >= 0 {
value = value[idx+1:]
if _, after, ok := strings.Cut(value, ","); ok {
value = after
}
return io.ReadAll(io.LimitReader(
base64StdDecoder(strings.NewReader(value)),
@@ -1155,56 +1145,58 @@ func buildTelegramForwardContext(msg *tgbotapi.Message) string {
return ""
}
func buildTelegramAudio(target string, file tgbotapi.RequestFileData) (tgbotapi.AudioConfig, error) {
// parseTelegramTarget resolves a target string into a numeric chat ID and an
// optional channel username. Exactly one of chatID or channelUsername will be
// set; callers can use this to construct any message config type.
func parseTelegramTarget(target string) (chatID int64, channelUsername string, err error) {
if strings.HasPrefix(target, "@") {
audio := tgbotapi.NewAudio(0, file)
audio.ChannelUsername = target
return audio, nil
return 0, target, nil
}
chatID, err := strconv.ParseInt(target, 10, 64)
chatID, err = strconv.ParseInt(target, 10, 64)
if err != nil {
return tgbotapi.AudioConfig{}, errors.New("telegram target must be @username or chat_id")
return 0, "", errors.New("telegram target must be @username or chat_id")
}
return tgbotapi.NewAudio(chatID, file), nil
return chatID, "", nil
}
func buildTelegramAudio(target string, file tgbotapi.RequestFileData) (tgbotapi.AudioConfig, error) {
chatID, channelUsername, err := parseTelegramTarget(target)
if err != nil {
return tgbotapi.AudioConfig{}, err
}
audio := tgbotapi.NewAudio(chatID, file)
audio.ChannelUsername = channelUsername
return audio, nil
}
func buildTelegramVoice(target string, file tgbotapi.RequestFileData) (tgbotapi.VoiceConfig, error) {
if strings.HasPrefix(target, "@") {
voice := tgbotapi.NewVoice(0, file)
voice.ChannelUsername = target
return voice, nil
}
chatID, err := strconv.ParseInt(target, 10, 64)
chatID, channelUsername, err := parseTelegramTarget(target)
if err != nil {
return tgbotapi.VoiceConfig{}, errors.New("telegram target must be @username or chat_id")
return tgbotapi.VoiceConfig{}, err
}
return tgbotapi.NewVoice(chatID, file), nil
voice := tgbotapi.NewVoice(chatID, file)
voice.ChannelUsername = channelUsername
return voice, nil
}
func buildTelegramVideo(target string, file tgbotapi.RequestFileData) (tgbotapi.VideoConfig, error) {
if strings.HasPrefix(target, "@") {
video := tgbotapi.NewVideo(0, file)
video.ChannelUsername = target
return video, nil
}
chatID, err := strconv.ParseInt(target, 10, 64)
chatID, channelUsername, err := parseTelegramTarget(target)
if err != nil {
return tgbotapi.VideoConfig{}, errors.New("telegram target must be @username or chat_id")
return tgbotapi.VideoConfig{}, err
}
return tgbotapi.NewVideo(chatID, file), nil
video := tgbotapi.NewVideo(chatID, file)
video.ChannelUsername = channelUsername
return video, nil
}
func buildTelegramAnimation(target string, file tgbotapi.RequestFileData) (tgbotapi.AnimationConfig, error) {
if strings.HasPrefix(target, "@") {
animation := tgbotapi.NewAnimation(0, file)
animation.ChannelUsername = target
return animation, nil
}
chatID, err := strconv.ParseInt(target, 10, 64)
chatID, channelUsername, err := parseTelegramTarget(target)
if err != nil {
return tgbotapi.AnimationConfig{}, errors.New("telegram target must be @username or chat_id")
return tgbotapi.AnimationConfig{}, err
}
return tgbotapi.NewAnimation(chatID, file), nil
animation := tgbotapi.NewAnimation(chatID, file)
animation.ChannelUsername = channelUsername
return animation, nil
}
func resolveTelegramParseMode(format channel.MessageFormat) string {
@@ -1424,8 +1416,8 @@ func (a *TelegramAdapter) ResolveAttachment(ctx context.Context, cfg channel.Cha
mime := strings.TrimSpace(attachment.Mime)
if mime == "" {
mime = strings.TrimSpace(resp.Header.Get("Content-Type"))
if idx := strings.Index(mime, ";"); idx >= 0 {
mime = strings.TrimSpace(mime[:idx])
if base, _, ok := strings.Cut(mime, ";"); ok {
mime = strings.TrimSpace(base)
}
}
size := attachment.Size