package robot import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "time" "cyberstrike-ai/internal/config" "github.com/bwmarrin/discordgo" lark "github.com/larksuite/oapi-sdk-go/v3" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" "github.com/slack-go/slack" ) // SendProactive sends a message without an inbound event. Platforms whose // reply credentials are event-scoped deliberately return an error instead of // pretending delivery succeeded. func SendProactive(ctx context.Context, cfg config.RobotsConfig, platform, externalUserID, message string) error { platform = strings.ToLower(strings.TrimSpace(platform)) userID := robotIdentityUserPart(externalUserID) if userID == "" { return fmt.Errorf("invalid robot recipient") } switch platform { case "telegram": if !cfg.Telegram.Enabled || strings.TrimSpace(cfg.Telegram.BotToken) == "" { return fmt.Errorf("telegram is not configured") } id, err := strconv.ParseInt(userID, 10, 64) if err != nil { return fmt.Errorf("invalid telegram user id: %w", err) } return telegramSendReply(ctx, nilSafeHTTPClient(), strings.TrimSpace(cfg.Telegram.BotToken), id, message) case "slack": if !cfg.Slack.Enabled || strings.TrimSpace(cfg.Slack.BotToken) == "" { return fmt.Errorf("slack is not configured") } api := slack.New(strings.TrimSpace(cfg.Slack.BotToken)) channel, _, _, err := api.OpenConversationContext(ctx, &slack.OpenConversationParameters{Users: []string{userID}}) if err != nil { return err } for _, chunk := range splitTextChunks(message, slackMaxMessageRunes) { if _, _, err = api.PostMessageContext(ctx, channel.ID, slack.MsgOptionText(chunk, false)); err != nil { return err } } return nil case "discord": if !cfg.Discord.Enabled || strings.TrimSpace(cfg.Discord.BotToken) == "" { return fmt.Errorf("discord is not configured") } token := strings.TrimSpace(cfg.Discord.BotToken) if !strings.HasPrefix(token, "Bot ") { token = "Bot " + token } session, err := discordgo.New(token) if err != nil { return err } channel, err := session.UserChannelCreate(userID) if err != nil { return err } for _, chunk := range splitTextChunks(message, discordMaxMessageRunes) { if _, err = session.ChannelMessageSend(channel.ID, chunk); err != nil { return err } } return nil case "wecom": return sendWecomProactive(ctx, cfg.Wecom, userID, message) case "lark": return sendLarkProactive(ctx, cfg.Lark, externalUserID, message) default: return fmt.Errorf("platform %s does not support proactive alerts yet", platform) } } func SupportsProactive(platform string) bool { switch strings.ToLower(strings.TrimSpace(platform)) { case "telegram", "slack", "discord", "wecom", "lark": return true default: return false } } func nilSafeHTTPClient() *http.Client { return &http.Client{Timeout: 15 * time.Second} } func sendWecomProactive(ctx context.Context, cfg config.RobotWecomConfig, userID, message string) error { if !cfg.Enabled || strings.TrimSpace(cfg.CorpID) == "" || strings.TrimSpace(cfg.Secret) == "" || cfg.AgentID == 0 { return fmt.Errorf("wecom proactive API is not configured") } tokenURL := "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + cfg.CorpID + "&corpsecret=" + cfg.Secret req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) if err != nil { return err } resp, err := nilSafeHTTPClient().Do(req) if err != nil { return err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return err } var tokenResp struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` AccessToken string `json:"access_token"` } if err := json.Unmarshal(body, &tokenResp); err != nil { return err } if tokenResp.ErrCode != 0 || tokenResp.AccessToken == "" { return fmt.Errorf("wecom token: %s", tokenResp.ErrMsg) } payload, _ := json.Marshal(map[string]interface{}{ "touser": userID, "msgtype": "text", "agentid": cfg.AgentID, "text": map[string]string{"content": message}, "safe": 0, }) sendReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token="+tokenResp.AccessToken, bytes.NewReader(payload)) if err != nil { return err } sendReq.Header.Set("Content-Type", "application/json") sendResp, err := nilSafeHTTPClient().Do(sendReq) if err != nil { return err } defer sendResp.Body.Close() result, _ := io.ReadAll(sendResp.Body) var parsed struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } if err := json.Unmarshal(result, &parsed); err != nil { return err } if parsed.ErrCode != 0 { return fmt.Errorf("wecom send: %s", parsed.ErrMsg) } return nil } func sendLarkProactive(ctx context.Context, cfg config.RobotLarkConfig, identity, message string) error { if !cfg.Enabled || strings.TrimSpace(cfg.AppID) == "" || strings.TrimSpace(cfg.AppSecret) == "" { return fmt.Errorf("lark is not configured") } receiveIDType, receiveID := "user_id", robotIdentityUserPart(identity) if idx := strings.LastIndex(identity, "|o:"); idx >= 0 { receiveIDType, receiveID = "open_id", strings.TrimSpace(identity[idx+3:]) } if idx := strings.LastIndex(identity, "|n:"); idx >= 0 { receiveIDType, receiveID = "union_id", strings.TrimSpace(identity[idx+3:]) } if receiveID == "" { return fmt.Errorf("invalid lark recipient") } content, _ := json.Marshal(larkTextContent{Text: message}) client := lark.NewClient(cfg.AppID, cfg.AppSecret) resp, err := client.Im.Message.Create(ctx, larkim.NewCreateMessageReqBuilder(). ReceiveIdType(receiveIDType). Body(larkim.NewCreateMessageReqBodyBuilder().ReceiveId(receiveID).MsgType(larkim.MsgTypeText).Content(string(content)).Build()).Build()) if err != nil { return err } if resp == nil || !resp.Success() { return fmt.Errorf("lark send failed") } return nil } func robotIdentityUserPart(identity string) string { identity = strings.TrimSpace(identity) if i := strings.LastIndex(identity, "|u:"); i >= 0 { return strings.TrimSpace(identity[i+3:]) } if strings.HasPrefix(identity, "u:") { return strings.TrimSpace(identity[2:]) } return identity }