diff --git a/internal/config/config.go b/internal/config/config.go index f9492688..750eab72 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -453,13 +453,17 @@ type MultiAgentAPIUpdate struct { ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"` } -// RobotsConfig 机器人配置(企业微信、钉钉、飞书、微信 iLink 等) +// RobotsConfig 机器人配置(企业微信、钉钉、飞书、微信 iLink、Telegram、Slack、Discord、QQ 等) type RobotsConfig struct { - Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略 - Wechat RobotWechatConfig `yaml:"wechat,omitempty" json:"wechat,omitempty"` // 微信(iLink 扫码绑定) - Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信 - Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉 - Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书 + Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略 + Wechat RobotWechatConfig `yaml:"wechat,omitempty" json:"wechat,omitempty"` // 微信(iLink 扫码绑定) + Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信 + Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉 + Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书 + Telegram RobotTelegramConfig `yaml:"telegram,omitempty" json:"telegram,omitempty"` // Telegram + Slack RobotSlackConfig `yaml:"slack,omitempty" json:"slack,omitempty"` // Slack + Discord RobotDiscordConfig `yaml:"discord,omitempty" json:"discord,omitempty"` // Discord + QQ RobotQQConfig `yaml:"qq,omitempty" json:"qq,omitempty"` // QQ 机器人 } // RobotWechatConfig 微信 iLink 机器人配置(个人微信 ClawBot / iLink 协议) @@ -525,6 +529,37 @@ type RobotLarkConfig struct { AllowChatIDFallback bool `yaml:"allow_chat_id_fallback" json:"allow_chat_id_fallback"` // 用户 ID 缺失时是否允许回退到 chat_id } +// RobotTelegramConfig Telegram 机器人配置(Bot API 长轮询) +type RobotTelegramConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + BotToken string `yaml:"bot_token" json:"bot_token"` + BotUsername string `yaml:"bot_username,omitempty" json:"bot_username,omitempty"` // 可选,用于群聊 @ 识别;留空则启动时 getMe + AllowGroupMessages bool `yaml:"allow_group_messages" json:"allow_group_messages"` // 群聊中仅响应 @ 机器人 + UpdateOffset int64 `yaml:"update_offset,omitempty" json:"update_offset,omitempty"` +} + +// RobotSlackConfig Slack 机器人配置(Socket Mode,无需公网回调) +type RobotSlackConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + BotToken string `yaml:"bot_token" json:"bot_token"` // xoxb- + AppToken string `yaml:"app_token" json:"app_token"` // xapp-(connections:write) +} + +// RobotDiscordConfig Discord 机器人配置(Gateway WebSocket) +type RobotDiscordConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + BotToken string `yaml:"bot_token" json:"bot_token"` + AllowGuildMessages bool `yaml:"allow_guild_messages" json:"allow_guild_messages"` // 服务器频道中仅响应 @ 机器人 +} + +// RobotQQConfig QQ 机器人配置(QQ 开放平台 WebSocket) +type RobotQQConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + AppID string `yaml:"app_id" json:"app_id"` + ClientSecret string `yaml:"client_secret" json:"client_secret"` + Sandbox bool `yaml:"sandbox" json:"sandbox"` // 沙箱环境(上线前测试) +} + type ServerConfig struct { Host string `yaml:"host" json:"host"` Port int `yaml:"port" json:"port"` diff --git a/internal/handler/config.go b/internal/handler/config.go index aa387106..284c5e22 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -808,6 +808,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { zap.Bool("wecom_enabled", h.config.Robots.Wecom.Enabled), zap.Bool("dingtalk_enabled", h.config.Robots.Dingtalk.Enabled), zap.Bool("lark_enabled", h.config.Robots.Lark.Enabled), + zap.Bool("telegram_enabled", h.config.Robots.Telegram.Enabled), + zap.Bool("slack_enabled", h.config.Robots.Slack.Enabled), + zap.Bool("discord_enabled", h.config.Robots.Discord.Enabled), + zap.Bool("qq_enabled", h.config.Robots.QQ.Enabled), ) } @@ -1870,6 +1874,28 @@ func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) { setStringInMap(larkNode, "app_secret", cfg.Lark.AppSecret) setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken) setBoolInMap(larkNode, "allow_chat_id_fallback", cfg.Lark.AllowChatIDFallback) + + telegramNode := ensureMap(robotsNode, "telegram") + setBoolInMap(telegramNode, "enabled", cfg.Telegram.Enabled) + setStringInMap(telegramNode, "bot_token", cfg.Telegram.BotToken) + setStringInMap(telegramNode, "bot_username", cfg.Telegram.BotUsername) + setBoolInMap(telegramNode, "allow_group_messages", cfg.Telegram.AllowGroupMessages) + + slackNode := ensureMap(robotsNode, "slack") + setBoolInMap(slackNode, "enabled", cfg.Slack.Enabled) + setStringInMap(slackNode, "bot_token", cfg.Slack.BotToken) + setStringInMap(slackNode, "app_token", cfg.Slack.AppToken) + + discordNode := ensureMap(robotsNode, "discord") + setBoolInMap(discordNode, "enabled", cfg.Discord.Enabled) + setStringInMap(discordNode, "bot_token", cfg.Discord.BotToken) + setBoolInMap(discordNode, "allow_guild_messages", cfg.Discord.AllowGuildMessages) + + qqNode := ensureMap(robotsNode, "qq") + setBoolInMap(qqNode, "enabled", cfg.QQ.Enabled) + setStringInMap(qqNode, "app_id", cfg.QQ.AppID) + setStringInMap(qqNode, "client_secret", cfg.QQ.ClientSecret) + setBoolInMap(qqNode, "sandbox", cfg.QQ.Sandbox) } func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) { diff --git a/internal/robot/discord.go b/internal/robot/discord.go new file mode 100644 index 00000000..4986e524 --- /dev/null +++ b/internal/robot/discord.go @@ -0,0 +1,121 @@ +package robot + +import ( + "context" + "strings" + + "cyberstrike-ai/internal/config" + + "github.com/bwmarrin/discordgo" + "go.uber.org/zap" +) + +const ( + discordPlatform = "discord" + discordMaxMessageRunes = 2000 +) + +// StartDiscord 启动 Discord Gateway(WebSocket,无需公网回调)。 +func StartDiscord(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) { + cfg := robotsCfg.Discord + if !cfg.Enabled || strings.TrimSpace(cfg.BotToken) == "" { + return + } + go runDiscordLoop(ctx, cfg, h, logger) +} + +func runDiscordLoop(ctx context.Context, cfg config.RobotDiscordConfig, h MessageHandler, logger *zap.Logger) { + backoff := reconnectInitial + for { + err := runDiscordSession(ctx, cfg, h, logger) + if ctx.Err() != nil { + logger.Info("Discord Gateway 已按配置关闭") + return + } + if err != nil { + logger.Warn("Discord Gateway 异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + if !waitReconnect(ctx, &backoff) { + return + } + } +} + +func runDiscordSession(ctx context.Context, cfg config.RobotDiscordConfig, h MessageHandler, logger *zap.Logger) error { + token := strings.TrimSpace(cfg.BotToken) + if !strings.HasPrefix(token, "Bot ") { + token = "Bot " + token + } + session, err := discordgo.New(token) + if err != nil { + return err + } + session.Identify.Intents = discordgo.IntentsGuildMessages | + discordgo.IntentsDirectMessages | + discordgo.IntentMessageContent + + session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { + if m == nil || m.Author == nil || m.Author.Bot { + return + } + text := strings.TrimSpace(m.Content) + if text == "" { + return + } + if m.GuildID != "" { + if !cfg.AllowGuildMessages { + return + } + if s.State.User == nil || !discordMentionsBot(m, s.State.User.ID) { + return + } + } + userID := discordSessionKey(m.GuildID, m.Author.ID) + logger.Info("Discord 收到消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(discordPlatform, userID, text) + discordPostReply(s, m.ChannelID, reply, logger) + }) + + if err := session.Open(); err != nil { + return err + } + logger.Info("Discord Gateway 已连接,等待收消息") + defer session.Close() + + <-ctx.Done() + return ctx.Err() +} + +func discordMentionsBot(m *discordgo.MessageCreate, botUserID string) bool { + if m == nil || botUserID == "" { + return false + } + for _, mention := range m.Mentions { + if mention != nil && mention.ID == botUserID { + return true + } + } + return strings.Contains(m.Content, "<@"+botUserID+">") || strings.Contains(m.Content, "<@!"+botUserID+">") +} + +func discordSessionKey(guildID, userID string) string { + guildID = strings.TrimSpace(guildID) + userID = strings.TrimSpace(userID) + if guildID == "" { + return "u:" + userID + } + return "g:" + guildID + "|u:" + userID +} + +func discordPostReply(s *discordgo.Session, channelID, reply string, logger *zap.Logger) { + reply = trimReply(reply) + if reply == "" { + return + } + for _, chunk := range splitTextChunks(reply, discordMaxMessageRunes) { + if _, err := s.ChannelMessageSend(channelID, chunk); err != nil { + logger.Warn("Discord 发送回复失败", zap.String("channel", channelID), zap.Error(err)) + return + } + } +} diff --git a/internal/robot/qq.go b/internal/robot/qq.go new file mode 100644 index 00000000..489d8909 --- /dev/null +++ b/internal/robot/qq.go @@ -0,0 +1,209 @@ +package robot + +import ( + "context" + "strings" + "sync" + + "cyberstrike-ai/internal/config" + + "github.com/tencent-connect/botgo" + "github.com/tencent-connect/botgo/dto" + "github.com/tencent-connect/botgo/event" + "github.com/tencent-connect/botgo/openapi" + "github.com/tencent-connect/botgo/token" + "go.uber.org/zap" +) + +const ( + qqPlatform = "qq" + qqMaxMessageRunes = 3500 +) + +var ( + qqHandlerMu sync.Mutex + qqHandler MessageHandler + qqLogger *zap.Logger + qqAPI openapi.OpenAPI +) + +// StartQQ 启动 QQ 机器人 WebSocket(C2C 与群 @,出站连接,无需公网回调)。 +func StartQQ(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) { + cfg := robotsCfg.QQ + if !cfg.Enabled || strings.TrimSpace(cfg.AppID) == "" || strings.TrimSpace(cfg.ClientSecret) == "" { + return + } + go runQQLoop(ctx, cfg, h, logger) +} + +func runQQLoop(ctx context.Context, cfg config.RobotQQConfig, h MessageHandler, logger *zap.Logger) { + backoff := reconnectInitial + for { + if ctx.Err() != nil { + logger.Info("QQ 机器人 WebSocket 已按配置关闭") + return + } + err := runQQSession(ctx, cfg, h, logger) + if ctx.Err() != nil { + return + } + if err != nil { + logger.Warn("QQ 机器人 WebSocket 异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + if !waitReconnect(ctx, &backoff) { + return + } + } +} + +func runQQSession(ctx context.Context, cfg config.RobotQQConfig, h MessageHandler, logger *zap.Logger) error { + appID := strings.TrimSpace(cfg.AppID) + secret := strings.TrimSpace(cfg.ClientSecret) + credentials := &token.QQBotCredentials{AppID: appID, AppSecret: secret} + tokenSource := token.NewQQBotTokenSource(credentials) + + if err := token.StartRefreshAccessToken(ctx, tokenSource); err != nil { + return err + } + + var api openapi.OpenAPI + if cfg.Sandbox { + api = botgo.NewSandboxOpenAPI(appID, tokenSource) + } else { + api = botgo.NewOpenAPI(appID, tokenSource) + } + + qqHandlerMu.Lock() + qqHandler = h + qqLogger = logger + qqAPI = api + qqHandlerMu.Unlock() + defer func() { + qqHandlerMu.Lock() + qqHandler = nil + qqLogger = nil + qqAPI = nil + qqHandlerMu.Unlock() + }() + + intents := event.RegisterHandlers( + event.C2CMessageEventHandler(handleQQC2CMessage), + event.GroupATMessageEventHandler(handleQQGroupATMessage), + ) + + wsInfo, err := api.WS(ctx, nil, "") + if err != nil { + return err + } + logger.Info("QQ 机器人 WebSocket 正在连接…", zap.String("app_id", appID), zap.Bool("sandbox", cfg.Sandbox)) + + done := make(chan error, 1) + go func() { + done <- botgo.NewSessionManager().Start(wsInfo, tokenSource, &intents) + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-done: + if err != nil { + return err + } + return nil + } +} + +func handleQQC2CMessage(payload *dto.WSPayload, data *dto.WSC2CMessageData) error { + if data == nil || data.Author == nil { + return nil + } + text := strings.TrimSpace(data.Content) + if text == "" { + return nil + } + userOpenID := strings.TrimSpace(data.Author.ID) + if userOpenID == "" { + return nil + } + userID := "u:" + userOpenID + qqHandlerMu.Lock() + h := qqHandler + logger := qqLogger + api := qqAPI + qqHandlerMu.Unlock() + if h == nil || api == nil { + return nil + } + logger.Info("QQ 收到 C2C 消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(qqPlatform, userID, text) + return qqPostC2CReply(context.Background(), api, userOpenID, payload, data.ID, reply, logger) +} + +func handleQQGroupATMessage(payload *dto.WSPayload, data *dto.WSGroupATMessageData) error { + if data == nil || data.Author == nil { + return nil + } + text := strings.TrimSpace(data.Content) + if text == "" { + return nil + } + userOpenID := strings.TrimSpace(data.Author.ID) + groupID := strings.TrimSpace(data.GroupID) + if userOpenID == "" { + return nil + } + userID := "g:" + groupID + "|u:" + userOpenID + qqHandlerMu.Lock() + h := qqHandler + logger := qqLogger + api := qqAPI + qqHandlerMu.Unlock() + if h == nil || api == nil { + return nil + } + logger.Info("QQ 收到群 @ 消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(qqPlatform, userID, text) + return qqPostGroupReply(context.Background(), api, groupID, payload, data.ID, reply, logger) +} + +func qqPostC2CReply(ctx context.Context, api openapi.OpenAPI, userOpenID string, payload *dto.WSPayload, msgID, reply string, logger *zap.Logger) error { + reply = trimReply(reply) + if reply == "" { + return nil + } + for _, chunk := range splitTextChunks(reply, qqMaxMessageRunes) { + msg := &dto.MessageToCreate{ + Content: chunk, + MsgID: msgID, + } + if payload != nil && payload.EventID != "" { + msg.EventID = payload.EventID + } + if _, err := api.PostC2CMessage(ctx, userOpenID, msg); err != nil { + logger.Warn("QQ 发送 C2C 回复失败", zap.String("to", userOpenID), zap.Error(err)) + return err + } + } + return nil +} + +func qqPostGroupReply(ctx context.Context, api openapi.OpenAPI, groupID string, payload *dto.WSPayload, msgID, reply string, logger *zap.Logger) error { + reply = trimReply(reply) + if reply == "" { + return nil + } + for _, chunk := range splitTextChunks(reply, qqMaxMessageRunes) { + msg := &dto.MessageToCreate{ + Content: chunk, + MsgID: msgID, + } + if payload != nil && payload.EventID != "" { + msg.EventID = payload.EventID + } + if _, err := api.PostGroupMessage(ctx, groupID, msg); err != nil { + logger.Warn("QQ 发送群消息回复失败", zap.String("group", groupID), zap.Error(err)) + return err + } + } + return nil +} diff --git a/internal/robot/reconnect.go b/internal/robot/reconnect.go new file mode 100644 index 00000000..4aee6c90 --- /dev/null +++ b/internal/robot/reconnect.go @@ -0,0 +1,38 @@ +package robot + +import ( + "context" + "time" +) + +const ( + reconnectInitial = 5 * time.Second + reconnectMax = 60 * time.Second +) + +func waitReconnect(ctx context.Context, backoff *time.Duration) bool { + if ctx.Err() != nil { + return false + } + select { + case <-ctx.Done(): + return false + case <-time.After(*backoff): + if *backoff < reconnectMax { + *backoff *= 2 + if *backoff > reconnectMax { + *backoff = reconnectMax + } + } + return true + } +} + +func bumpBackoff(backoff *time.Duration) { + if *backoff < reconnectMax { + *backoff *= 2 + if *backoff > reconnectMax { + *backoff = reconnectMax + } + } +} diff --git a/internal/robot/slack.go b/internal/robot/slack.go new file mode 100644 index 00000000..1e30a26a --- /dev/null +++ b/internal/robot/slack.go @@ -0,0 +1,135 @@ +package robot + +import ( + "context" + "strings" + + "cyberstrike-ai/internal/config" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" + "go.uber.org/zap" +) + +const ( + slackPlatform = "slack" + slackMaxMessageRunes = 3900 +) + +// StartSlack 启动 Slack Socket Mode(出站 WebSocket,无需公网回调)。 +func StartSlack(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) { + cfg := robotsCfg.Slack + if !cfg.Enabled || strings.TrimSpace(cfg.BotToken) == "" || strings.TrimSpace(cfg.AppToken) == "" { + return + } + go runSlackLoop(ctx, cfg, h, logger) +} + +func runSlackLoop(ctx context.Context, cfg config.RobotSlackConfig, h MessageHandler, logger *zap.Logger) { + backoff := reconnectInitial + for { + err := runSlackSocket(ctx, cfg, h, logger) + if ctx.Err() != nil { + logger.Info("Slack Socket Mode 已按配置关闭") + return + } + if err != nil { + logger.Warn("Slack Socket Mode 异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + if !waitReconnect(ctx, &backoff) { + return + } + } +} + +func runSlackSocket(ctx context.Context, cfg config.RobotSlackConfig, h MessageHandler, logger *zap.Logger) error { + api := slack.New( + strings.TrimSpace(cfg.BotToken), + slack.OptionAppLevelToken(strings.TrimSpace(cfg.AppToken)), + ) + client := socketmode.New(api) + logger.Info("Slack Socket Mode 正在连接…") + + go func() { + for evt := range client.Events { + switch evt.Type { + case socketmode.EventTypeEventsAPI: + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + continue + } + client.Ack(*evt.Request) + if eventsAPIEvent.Type != slackevents.CallbackEvent { + continue + } + switch ev := eventsAPIEvent.InnerEvent.Data.(type) { + case *slackevents.MessageEvent: + handleSlackMessage(ctx, api, eventsAPIEvent.TeamID, ev, h, logger) + case *slackevents.AppMentionEvent: + handleSlackAppMention(ctx, api, eventsAPIEvent.TeamID, ev, h, logger) + } + case socketmode.EventTypeConnecting: + logger.Info("Slack Socket Mode 正在连接…") + case socketmode.EventTypeConnected: + logger.Info("Slack Socket Mode 已连接,等待收消息") + } + } + }() + + return client.RunContext(ctx) +} + +func handleSlackMessage(ctx context.Context, api *slack.Client, teamID string, ev *slackevents.MessageEvent, h MessageHandler, logger *zap.Logger) { + if ev == nil || ev.BotID != "" || ev.SubType != "" { + return + } + if ev.ChannelType != "im" { + return + } + text := strings.TrimSpace(ev.Text) + if text == "" { + return + } + userID := slackSessionKey(teamID, ev.User) + logger.Info("Slack 收到消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(slackPlatform, userID, text) + slackPostReply(ctx, api, ev.Channel, reply, logger) +} + +func handleSlackAppMention(ctx context.Context, api *slack.Client, teamID string, ev *slackevents.AppMentionEvent, h MessageHandler, logger *zap.Logger) { + if ev == nil || ev.BotID != "" { + return + } + text := strings.TrimSpace(ev.Text) + if text == "" { + return + } + userID := slackSessionKey(teamID, ev.User) + logger.Info("Slack 收到 @ 消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(slackPlatform, userID, text) + slackPostReply(ctx, api, ev.Channel, reply, logger) +} + +func slackSessionKey(teamID, userID string) string { + teamID = strings.TrimSpace(teamID) + userID = strings.TrimSpace(userID) + if teamID == "" { + teamID = "default" + } + return "t:" + teamID + "|u:" + userID +} + +func slackPostReply(ctx context.Context, api *slack.Client, channel, reply string, logger *zap.Logger) { + reply = trimReply(reply) + if reply == "" { + return + } + for _, chunk := range splitTextChunks(reply, slackMaxMessageRunes) { + _, _, err := api.PostMessageContext(ctx, channel, slack.MsgOptionText(chunk, false)) + if err != nil { + logger.Warn("Slack 发送回复失败", zap.String("channel", channel), zap.Error(err)) + return + } + } +} diff --git a/internal/robot/split.go b/internal/robot/split.go new file mode 100644 index 00000000..c5425b84 --- /dev/null +++ b/internal/robot/split.go @@ -0,0 +1,29 @@ +package robot + +import "strings" + +// splitTextChunks splits text into chunks no longer than maxRunes (rune count). +func splitTextChunks(text string, maxRunes int) []string { + text = strings.TrimSpace(text) + if text == "" || maxRunes <= 0 { + return nil + } + runes := []rune(text) + if len(runes) <= maxRunes { + return []string{text} + } + var out []string + for len(runes) > 0 { + end := maxRunes + if end > len(runes) { + end = len(runes) + } + out = append(out, string(runes[:end])) + runes = runes[end:] + } + return out +} + +func trimReply(s string) string { + return strings.TrimSpace(s) +} diff --git a/internal/robot/telegram.go b/internal/robot/telegram.go new file mode 100644 index 00000000..f8712d57 --- /dev/null +++ b/internal/robot/telegram.go @@ -0,0 +1,262 @@ +package robot + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "cyberstrike-ai/internal/config" + + "go.uber.org/zap" +) + +const ( + telegramPlatform = "telegram" + telegramAPIBase = "https://api.telegram.org" + telegramLongPollSec = 30 + telegramMaxMessageRunes = 4096 +) + +type telegramUpdate struct { + UpdateID int `json:"update_id"` + Message *telegramMessage `json:"message"` +} + +type telegramMessage struct { + MessageID int64 `json:"message_id"` + Chat telegramChat `json:"chat"` + From *telegramUser `json:"from"` + Text string `json:"text"` + Entities []telegramEntity `json:"entities"` +} + +type telegramChat struct { + ID int64 `json:"id"` + Type string `json:"type"` + Title string `json:"title"` +} + +type telegramUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + IsBot bool `json:"is_bot"` +} + +type telegramEntity struct { + Type string `json:"type"` + Offset int `json:"offset"` + Length int `json:"length"` +} + +type telegramGetUpdatesResp struct { + OK bool `json:"ok"` + Result []telegramUpdate `json:"result"` + Description string `json:"description"` +} + +type telegramBotMe struct { + OK bool `json:"ok"` + Result struct { + ID int64 `json:"id"` + Username string `json:"username"` + } `json:"result"` +} + +// StartTelegram 启动 Telegram Bot 长轮询(getUpdates,无需公网回调)。 +func StartTelegram(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) { + cfg := robotsCfg.Telegram + if !cfg.Enabled || strings.TrimSpace(cfg.BotToken) == "" { + return + } + go runTelegramLoop(ctx, cfg, h, logger) +} + +func runTelegramLoop(ctx context.Context, cfg config.RobotTelegramConfig, h MessageHandler, logger *zap.Logger) { + backoff := reconnectInitial + for { + err := runTelegramPoll(ctx, cfg, h, logger) + if ctx.Err() != nil { + logger.Info("Telegram 长轮询已按配置关闭") + return + } + if err != nil { + logger.Warn("Telegram 长轮询异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + if !waitReconnect(ctx, &backoff) { + return + } + } +} + +func runTelegramPoll(ctx context.Context, cfg config.RobotTelegramConfig, h MessageHandler, logger *zap.Logger) error { + token := strings.TrimSpace(cfg.BotToken) + botUsername := strings.TrimSpace(cfg.BotUsername) + if botUsername == "" { + if name, err := telegramGetMe(ctx, token); err != nil { + logger.Warn("Telegram getMe 失败", zap.Error(err)) + } else { + botUsername = name + } + } + offset := cfg.UpdateOffset + logger.Info("Telegram 长轮询已启动", zap.String("bot", botUsername)) + client := &http.Client{Timeout: telegramLongPollSec*time.Second + 10*time.Second} + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + updates, err := telegramGetUpdates(ctx, client, token, offset) + if err != nil { + return err + } + for _, u := range updates { + next := int64(u.UpdateID) + 1 + if next > offset { + offset = next + } + if u.Message == nil || u.Message.From == nil || u.Message.From.IsBot { + continue + } + text := strings.TrimSpace(u.Message.Text) + if text == "" { + continue + } + chatType := strings.ToLower(strings.TrimSpace(u.Message.Chat.Type)) + if chatType != "private" { + if !cfg.AllowGroupMessages { + continue + } + if botUsername != "" && !telegramMentionsBot(text, u.Message.Entities, botUsername) { + continue + } + } + userID := telegramSessionKey(chatType, u.Message.Chat.ID, u.Message.From.ID) + logger.Info("Telegram 收到消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(telegramPlatform, userID, text) + if err := telegramSendReply(ctx, client, token, u.Message.Chat.ID, reply); err != nil { + logger.Warn("Telegram 发送回复失败", zap.String("to", userID), zap.Error(err)) + } + } + } +} + +func telegramSessionKey(chatType string, chatID, fromUserID int64) string { + if chatType == "private" { + return fmt.Sprintf("u:%d", fromUserID) + } + return fmt.Sprintf("g:%d|u:%d", chatID, fromUserID) +} + +func telegramMentionsBot(text string, entities []telegramEntity, botUsername string) bool { + needle := "@" + strings.TrimPrefix(strings.ToLower(botUsername), "@") + lower := strings.ToLower(text) + if strings.Contains(lower, needle) { + return true + } + for _, e := range entities { + if e.Type != "mention" { + continue + } + if e.Offset < 0 || e.Length <= 0 || e.Offset+e.Length > len(text) { + continue + } + mention := strings.ToLower(text[e.Offset : e.Offset+e.Length]) + if mention == needle { + return true + } + } + return false +} + +func telegramAPIURL(token, method string) string { + return fmt.Sprintf("%s/bot%s/%s", telegramAPIBase, token, method) +} + +func telegramGetMe(ctx context.Context, token string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, telegramAPIURL(token, "getMe"), nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var parsed telegramBotMe + if err := json.Unmarshal(body, &parsed); err != nil { + return "", err + } + if !parsed.OK { + return "", fmt.Errorf("getMe failed: %s", string(body)) + } + return parsed.Result.Username, nil +} + +func telegramGetUpdates(ctx context.Context, client *http.Client, token string, offset int64) ([]telegramUpdate, error) { + url := fmt.Sprintf("%s?timeout=%d&allowed_updates=%s", telegramAPIURL(token, "getUpdates"), telegramLongPollSec, `["message"]`) + if offset > 0 { + url += fmt.Sprintf("&offset=%d", offset) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var parsed telegramGetUpdatesResp + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + if !parsed.OK { + if parsed.Description != "" { + return nil, fmt.Errorf("getUpdates: %s", parsed.Description) + } + return nil, fmt.Errorf("getUpdates failed: %s", string(body)) + } + return parsed.Result, nil +} + +func telegramSendReply(ctx context.Context, client *http.Client, token string, chatID int64, reply string) error { + reply = trimReply(reply) + if reply == "" { + return nil + } + for _, chunk := range splitTextChunks(reply, telegramMaxMessageRunes) { + payload := map[string]interface{}{ + "chat_id": chatID, + "text": chunk, + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, telegramAPIURL(token, "sendMessage"), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("sendMessage status %d", resp.StatusCode) + } + } + return nil +}