443 lines
12 KiB
Go
443 lines
12 KiB
Go
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
|
||
}
|