mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-06 20:47:57 +02:00
Add files via upload
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user