Files
ops-assistant/internal/channel/channel.go
2026-03-19 21:23:28 +08:00

443 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}