package channel import ( "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "ops-assistant/config" "ops-assistant/models" "golang.org/x/crypto/pbkdf2" "gorm.io/gorm" ) type UnifiedMessage struct { Platform string `json:"platform"` EventID string `json:"event_id"` ChatID string `json:"chat_id"` UserID string `json:"user_id"` Text string `json:"text"` } const ( encPrefixV1 = "enc:v1:" encPrefixV2 = "enc:v2:" ) var secretCipherV1 *cipherContext var secretCipherV2 *cipherContext type cipherContext struct { aead cipher.AEAD } func InitSecretCipher(key string) error { k1 := deriveKey32Legacy(key) block1, err := aes.NewCipher(k1) if err != nil { return err } aead1, err := cipher.NewGCM(block1) if err != nil { return err } secretCipherV1 = &cipherContext{aead: aead1} k2 := deriveKey32V2(key) block2, err := aes.NewCipher(k2) if err != nil { return err } aead2, err := cipher.NewGCM(block2) if err != nil { return err } secretCipherV2 = &cipherContext{aead: aead2} return nil } func deriveKey32Legacy(s string) []byte { b := []byte(s) out := make([]byte, 32) if len(b) >= 32 { copy(out, b[:32]) return out } copy(out, b) for i := len(b); i < 32; i++ { out[i] = byte((i * 131) % 251) } return out } func deriveKey32V2(s string) []byte { if strings.TrimSpace(s) == "" { return make([]byte, 32) } // PBKDF2 for deterministic 32-byte key derivation return pbkdf2.Key([]byte(s), []byte("ops-assistant-v1"), 200000, 32, sha256.New) } func encryptString(plain string) (string, error) { if secretCipherV2 == nil { return plain, errors.New("cipher not initialized") } nonce := make([]byte, secretCipherV2.aead.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", err } ciphertext := secretCipherV2.aead.Seal(nil, nonce, []byte(plain), nil) buf := append(nonce, ciphertext...) return encPrefixV2 + base64.StdEncoding.EncodeToString(buf), nil } func decryptString(raw string) (string, error) { if !strings.HasPrefix(raw, encPrefixV1) && !strings.HasPrefix(raw, encPrefixV2) { return raw, nil } if strings.HasPrefix(raw, encPrefixV2) { if secretCipherV2 == nil { return "", errors.New("cipher not initialized") } data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, encPrefixV2)) if err != nil { return "", err } ns := secretCipherV2.aead.NonceSize() if len(data) <= ns { return "", errors.New("invalid ciphertext") } nonce := data[:ns] ct := data[ns:] pt, err := secretCipherV2.aead.Open(nil, nonce, ct, nil) if err != nil { return "", err } return string(pt), nil } if secretCipherV1 == nil { return "", errors.New("cipher not initialized") } data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, encPrefixV1)) if err != nil { return "", err } ns := secretCipherV1.aead.NonceSize() if len(data) <= ns { return "", errors.New("invalid ciphertext") } nonce := data[:ns] ct := data[ns:] pt, err := secretCipherV1.aead.Open(nil, nonce, ct, nil) if err != nil { return "", err } return string(pt), nil } func maybeDecrypt(raw string) string { if strings.TrimSpace(raw) == "" { return raw } pt, err := decryptString(raw) if err != nil { return raw } return pt } func MaybeDecryptPublic(raw string) string { return maybeDecrypt(raw) } func EncryptSecretJSON(raw string) string { if strings.TrimSpace(raw) == "" { return raw } if strings.HasPrefix(raw, encPrefixV1) || strings.HasPrefix(raw, encPrefixV2) { return raw } if secretCipherV2 == nil { return raw } enc, err := encryptString(raw) if err != nil { return raw } return enc } type telegramSecret struct { Token string `json:"token"` } type qqSecret struct { AppID string `json:"appid"` Secret string `json:"secret"` } type feishuSecret struct { AppID string `json:"app_id"` AppSecret string `json:"app_secret"` VerificationToken string `json:"verification_token"` EncryptKey string `json:"encrypt_key"` } func parseJSON(raw string, out any) { if strings.TrimSpace(raw) == "" { return } _ = json.Unmarshal([]byte(raw), out) } // ApplyChannelConfig 从数据库渠道配置覆盖运行时配置(优先级:DB > YAML) func ApplyChannelConfig(db *gorm.DB, cfg *config.Config) error { var rows []models.ChannelConfig if err := db.Find(&rows).Error; err != nil { return err } for _, row := range rows { switch row.Platform { case "telegram": sec := telegramSecret{} parseJSON(maybeDecrypt(row.SecretJSON), &sec) cfg.Telegram.Enabled = row.Enabled if strings.TrimSpace(sec.Token) != "" { cfg.Telegram.Token = strings.TrimSpace(sec.Token) } case "qqbot_official": sec := qqSecret{} parseJSON(maybeDecrypt(row.SecretJSON), &sec) cfg.QQBot.Enabled = row.Enabled if strings.TrimSpace(sec.AppID) != "" { cfg.QQBot.AppID = strings.TrimSpace(sec.AppID) } if strings.TrimSpace(sec.Secret) != "" { cfg.QQBot.Secret = strings.TrimSpace(sec.Secret) } case "feishu": sec := feishuSecret{} parseJSON(maybeDecrypt(row.SecretJSON), &sec) cfg.Feishu.Enabled = row.Enabled if strings.TrimSpace(sec.AppID) != "" { cfg.Feishu.AppID = strings.TrimSpace(sec.AppID) } if strings.TrimSpace(sec.AppSecret) != "" { cfg.Feishu.AppSecret = strings.TrimSpace(sec.AppSecret) } if strings.TrimSpace(sec.VerificationToken) != "" { cfg.Feishu.VerificationToken = strings.TrimSpace(sec.VerificationToken) } if strings.TrimSpace(sec.EncryptKey) != "" { cfg.Feishu.EncryptKey = strings.TrimSpace(sec.EncryptKey) } } } return nil } func httpClient() *http.Client { return &http.Client{Timeout: 8 * time.Second} } func TestChannelConnectivity(ctx context.Context, row models.ChannelConfig) (status, detail string) { if !row.Enabled { return "disabled", "渠道未启用" } switch row.Platform { case "telegram": sec := telegramSecret{} parseJSON(maybeDecrypt(row.SecretJSON), &sec) if strings.TrimSpace(sec.Token) == "" { return "error", "telegram token 为空" } url := fmt.Sprintf("https://api.telegram.org/bot%s/getMe", strings.TrimSpace(sec.Token)) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) resp, err := httpClient().Do(req) if err != nil { return "error", err.Error() } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if resp.StatusCode != 200 || !strings.Contains(string(body), `"ok":true`) { return "error", fmt.Sprintf("telegram getMe失败: http=%d", resp.StatusCode) } return "ok", "telegram getMe 成功" case "qqbot_official": sec := qqSecret{} parseJSON(maybeDecrypt(row.SecretJSON), &sec) if strings.TrimSpace(sec.AppID) == "" || strings.TrimSpace(sec.Secret) == "" { return "error", "qq appid/secret 为空" } payload, _ := json.Marshal(map[string]string{"appId": strings.TrimSpace(sec.AppID), "clientSecret": strings.TrimSpace(sec.Secret)}) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://bots.qq.com/app/getAppAccessToken", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := httpClient().Do(req) if err != nil { return "error", err.Error() } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if resp.StatusCode != 200 || !strings.Contains(string(body), "access_token") { return "error", fmt.Sprintf("qq access token 获取失败: http=%d", resp.StatusCode) } return "ok", "qq access token 获取成功" case "feishu": sec := feishuSecret{} parseJSON(maybeDecrypt(row.SecretJSON), &sec) if strings.TrimSpace(sec.AppID) == "" || strings.TrimSpace(sec.AppSecret) == "" { return "error", "feishu app_id/app_secret 为空" } tk, err := GetFeishuTenantToken(ctx, strings.TrimSpace(sec.AppID), strings.TrimSpace(sec.AppSecret)) if err != nil || strings.TrimSpace(tk) == "" { if err == nil { err = fmt.Errorf("token 为空") } return "error", err.Error() } return "ok", "feishu tenant_access_token 获取成功" default: return "error", "未知平台" } } func ParseFeishuInbound(body []byte, verificationToken string) (*UnifiedMessage, string, error) { // url_verification var verifyReq struct { Type string `json:"type"` Challenge string `json:"challenge"` Token string `json:"token"` } if err := json.Unmarshal(body, &verifyReq); err == nil && verifyReq.Type == "url_verification" { if strings.TrimSpace(verificationToken) != "" && verifyReq.Token != verificationToken { return nil, "", fmt.Errorf("verification token mismatch") } return nil, verifyReq.Challenge, nil } var event struct { Header struct { EventID string `json:"event_id"` EventType string `json:"event_type"` } `json:"header"` Event struct { Sender struct { SenderID struct { OpenID string `json:"open_id"` } `json:"sender_id"` } `json:"sender"` Message struct { MessageID string `json:"message_id"` ChatID string `json:"chat_id"` Content string `json:"content"` } `json:"message"` } `json:"event"` } if err := json.Unmarshal(body, &event); err != nil { return nil, "", err } if event.Header.EventType != "im.message.receive_v1" { return nil, "", nil } eventID := strings.TrimSpace(event.Header.EventID) if eventID == "" { eventID = strings.TrimSpace(event.Event.Message.MessageID) } if eventID == "" { return nil, "", fmt.Errorf("missing event id") } var content struct { Text string `json:"text"` } _ = json.Unmarshal([]byte(event.Event.Message.Content), &content) text := strings.TrimSpace(content.Text) if text == "" { return nil, "", nil } return &UnifiedMessage{ Platform: "feishu", EventID: eventID, ChatID: strings.TrimSpace(event.Event.Message.ChatID), UserID: strings.TrimSpace(event.Event.Sender.SenderID.OpenID), Text: text, }, "", nil } func GetFeishuTenantToken(ctx context.Context, appID, appSecret string) (string, error) { payload, _ := json.Marshal(map[string]string{"app_id": appID, "app_secret": appSecret}) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := httpClient().Do(req) if err != nil { return "", err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) if resp.StatusCode != 200 { return "", fmt.Errorf("http=%d", resp.StatusCode) } var out struct { Code int `json:"code"` Msg string `json:"msg"` TenantAccessToken string `json:"tenant_access_token"` } if err := json.Unmarshal(body, &out); err != nil { return "", err } if out.Code != 0 || strings.TrimSpace(out.TenantAccessToken) == "" { if out.Msg == "" { out.Msg = "获取token失败" } return "", fmt.Errorf(out.Msg) } return out.TenantAccessToken, nil } func SendFeishuText(ctx context.Context, tenantToken, receiveID, text string) error { contentBytes, _ := json.Marshal(map[string]string{"text": text}) payload, _ := json.Marshal(map[string]string{ "receive_id": receiveID, "msg_type": "text", "content": string(contentBytes), }) url := "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id" req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+tenantToken) resp, err := httpClient().Do(req) if err != nil { return err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) if resp.StatusCode != 200 { return fmt.Errorf("http=%d", resp.StatusCode) } if !strings.Contains(string(body), `"code":0`) { return fmt.Errorf("feishu send failed") } return nil }