Add files via upload

This commit is contained in:
公明
2026-07-06 13:58:12 +08:00
committed by GitHub
parent ecc75ee326
commit a63b535a68
8 changed files with 861 additions and 6 deletions
+41 -6
View File
@@ -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"`
+26
View File
@@ -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) {
+121
View File
@@ -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 GatewayWebSocket,无需公网回调)。
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
}
}
}
+209
View File
@@ -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
}
+38
View File
@@ -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
}
}
}
+135
View File
@@ -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
}
}
}
+29
View File
@@ -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)
}
+262
View File
@@ -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
}