mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
43dc97e48c
- Moved cmd/, core/, plugins/, pkg/, downstream/, and main.go into backend/ directory - Batch updated all Go source files to import github.com/Rain-kl/Wavelet/backend/... - Updated Makefile, scripts/swagger.sh, architecture guards, and platform skills - Passed all quality gates (100% tests, 0 lint issues, clean build)
339 lines
8.5 KiB
Go
339 lines
8.5 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package message_gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tencent-connect/botgo/token"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const defaultTelegramAPI = "https://api.telegram.org"
|
|
|
|
// Field is one admin form field.
|
|
type Field struct {
|
|
Key string `json:"key"`
|
|
Type string `json:"type"`
|
|
Required bool `json:"required"`
|
|
}
|
|
|
|
// Definition describes a channel type form.
|
|
type Definition struct {
|
|
Type string `json:"type"`
|
|
Fields []Field `json:"fields"`
|
|
}
|
|
|
|
// ChannelDTO represents a channel for admin consumption.
|
|
type ChannelDTO struct {
|
|
ID uint64 `json:"id,string"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
OwnerScope string `json:"owner_scope"`
|
|
OwnerID *uint64 `json:"owner_id,string,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
Credentials map[string]string `json:"credentials"`
|
|
Extra map[string]string `json:"extra"`
|
|
}
|
|
|
|
// CreateChannelRequest is admin create payload.
|
|
type CreateChannelRequest struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Enabled *bool `json:"enabled"`
|
|
Credentials map[string]string `json:"credentials"`
|
|
Extra map[string]string `json:"extra"`
|
|
}
|
|
|
|
// UpdateChannelRequest is admin update payload.
|
|
type UpdateChannelRequest struct {
|
|
Name string `json:"name"`
|
|
Enabled *bool `json:"enabled"`
|
|
Credentials map[string]string `json:"credentials"`
|
|
Extra map[string]string `json:"extra"`
|
|
}
|
|
|
|
func listDefinitions() []Definition {
|
|
return []Definition{
|
|
{
|
|
Type: MessageChannelTypeTelegram,
|
|
Fields: []Field{
|
|
{Key: "token", Type: "password", Required: true},
|
|
{Key: "api_base", Type: "text", Required: false},
|
|
},
|
|
},
|
|
{
|
|
Type: MessageChannelTypeQQ,
|
|
Fields: []Field{
|
|
{Key: "app_id", Type: "text", Required: true},
|
|
{Key: "client_secret", Type: "password", Required: true},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func createChannel(ctx context.Context, req CreateChannelRequest) (ChannelDTO, error) {
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
return ChannelDTO{}, errors.New(errNameRequired)
|
|
}
|
|
channelType := strings.TrimSpace(req.Type)
|
|
if channelType != MessageChannelTypeTelegram && channelType != MessageChannelTypeQQ {
|
|
return ChannelDTO{}, errors.New(errTypeInvalid)
|
|
}
|
|
creds := req.Credentials
|
|
if creds == nil {
|
|
creds = map[string]string{}
|
|
}
|
|
if err := validateCredentials(channelType, creds, false); err != nil {
|
|
return ChannelDTO{}, err
|
|
}
|
|
cipher, err := EncryptCredentials(creds)
|
|
if err != nil {
|
|
return ChannelDTO{}, err
|
|
}
|
|
extra := req.Extra
|
|
if extra == nil {
|
|
extra = map[string]string{}
|
|
}
|
|
enabled := true
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
row := &MessageChannel{
|
|
Name: name,
|
|
Type: channelType,
|
|
OwnerScope: MessageOwnerScopeSystem,
|
|
Enabled: enabled,
|
|
Credentials: cipher,
|
|
Extra: EncodeExtra(extra),
|
|
}
|
|
if err := CreateMessageChannel(ctx, row); err != nil {
|
|
return ChannelDTO{}, err
|
|
}
|
|
return toDTO(row, creds, extra), nil
|
|
}
|
|
|
|
func updateChannel(ctx context.Context, id uint64, req UpdateChannelRequest) (ChannelDTO, error) {
|
|
row, err := GetMessageChannel(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ChannelDTO{}, errors.New(errChannelNotFound)
|
|
}
|
|
return ChannelDTO{}, err
|
|
}
|
|
creds, err := DecryptCredentials(row.Credentials)
|
|
if err != nil {
|
|
return ChannelDTO{}, err
|
|
}
|
|
extra := ParseExtra(row.Extra)
|
|
|
|
if name := strings.TrimSpace(req.Name); name != "" {
|
|
row.Name = name
|
|
}
|
|
if req.Enabled != nil {
|
|
row.Enabled = *req.Enabled
|
|
}
|
|
if req.Extra != nil {
|
|
extra = req.Extra
|
|
}
|
|
if len(req.Credentials) > 0 {
|
|
merged := make(map[string]string, len(creds))
|
|
for k, v := range creds {
|
|
merged[k] = v
|
|
}
|
|
for k, v := range req.Credentials {
|
|
if strings.TrimSpace(v) == "" {
|
|
continue
|
|
}
|
|
merged[k] = v
|
|
}
|
|
if err := validateCredentials(row.Type, merged, true); err != nil {
|
|
return ChannelDTO{}, err
|
|
}
|
|
creds = merged
|
|
}
|
|
|
|
cipher, err := EncryptCredentials(creds)
|
|
if err != nil {
|
|
return ChannelDTO{}, err
|
|
}
|
|
row.Credentials = cipher
|
|
row.Extra = EncodeExtra(extra)
|
|
if err := UpdateMessageChannel(ctx, row); err != nil {
|
|
return ChannelDTO{}, err
|
|
}
|
|
return toDTO(row, creds, extra), nil
|
|
}
|
|
|
|
func listChannels(ctx context.Context) ([]ChannelDTO, error) {
|
|
rows, err := ListMessageChannels(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]ChannelDTO, 0, len(rows))
|
|
for i := range rows {
|
|
creds, _ := DecryptCredentials(rows[i].Credentials)
|
|
extra := ParseExtra(rows[i].Extra)
|
|
out = append(out, toDTO(&rows[i], creds, extra))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func deleteChannel(ctx context.Context, id uint64) error {
|
|
if _, err := GetMessageChannel(ctx, id); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return errors.New(errChannelNotFound)
|
|
}
|
|
return err
|
|
}
|
|
return DeleteMessageChannel(ctx, id)
|
|
}
|
|
|
|
func probeChannel(ctx context.Context, id uint64) error {
|
|
row, err := GetMessageChannel(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return errors.New(errChannelNotFound)
|
|
}
|
|
return err
|
|
}
|
|
creds, err := DecryptCredentials(row.Credentials)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch row.Type {
|
|
case MessageChannelTypeTelegram:
|
|
return probeTelegram(ctx, creds)
|
|
case MessageChannelTypeQQ:
|
|
return probeQQ(ctx, creds)
|
|
default:
|
|
return errors.New(errTypeInvalid)
|
|
}
|
|
}
|
|
|
|
func probeTelegram(ctx context.Context, creds map[string]string) error {
|
|
tok := creds["token"]
|
|
if strings.TrimSpace(tok) == "" {
|
|
return errors.New("missing telegram bot token")
|
|
}
|
|
base := creds["api_base"]
|
|
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
|
if base == "" {
|
|
base = defaultTelegramAPI
|
|
}
|
|
url := fmt.Sprintf("%s/bot%s/getMe", base, tok)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("telegram getMe failed (%d): %s", resp.StatusCode, string(body))
|
|
}
|
|
var res struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
if err := json.Unmarshal(body, &res); err != nil {
|
|
return err
|
|
}
|
|
if !res.OK {
|
|
return fmt.Errorf("telegram returned ok=false: %s", string(body))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func probeQQ(_ context.Context, creds map[string]string) error {
|
|
appID := strings.TrimSpace(creds["app_id"])
|
|
secret := strings.TrimSpace(creds["app_secret"])
|
|
if appID == "" || secret == "" {
|
|
return errors.New("missing qq app_id or app_secret")
|
|
}
|
|
credentials := &token.QQBotCredentials{
|
|
AppID: appID,
|
|
AppSecret: secret,
|
|
}
|
|
tokSrc := token.NewQQBotTokenSource(credentials)
|
|
tok, err := tokSrc.Token()
|
|
if err != nil {
|
|
return fmt.Errorf("qq token fetch failed: %w", err)
|
|
}
|
|
if tok == nil || tok.AccessToken == "" {
|
|
return errors.New("qq returned empty access token")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCredentials(t string, creds map[string]string, isUpdate bool) error {
|
|
switch t {
|
|
case MessageChannelTypeTelegram:
|
|
tok := creds["token"]
|
|
if strings.TrimSpace(tok) == "" && !isUpdate {
|
|
return errors.New(errTelegramTokenRequired)
|
|
}
|
|
if base, ok := creds["api_base"]; ok && strings.TrimSpace(base) != "" {
|
|
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
|
return errors.New("api_base must start with http:// or https://")
|
|
}
|
|
}
|
|
case MessageChannelTypeQQ:
|
|
appID := creds["app_id"]
|
|
secret := creds["client_secret"]
|
|
if (strings.TrimSpace(appID) == "" || strings.TrimSpace(secret) == "") && !isUpdate {
|
|
return errors.New(errQQCredentialsRequired)
|
|
}
|
|
default:
|
|
return errors.New(errTypeInvalid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func toDTO(row *MessageChannel, creds, extra map[string]string) ChannelDTO {
|
|
return ChannelDTO{
|
|
ID: row.ID,
|
|
Name: row.Name,
|
|
Type: row.Type,
|
|
OwnerScope: row.OwnerScope,
|
|
OwnerID: row.OwnerID,
|
|
Enabled: row.Enabled,
|
|
Credentials: maskCredentials(row.Type, creds),
|
|
Extra: extra,
|
|
}
|
|
}
|
|
|
|
func maskCredentials(_ string, in map[string]string) map[string]string {
|
|
out := make(map[string]string, len(in))
|
|
for k, v := range in {
|
|
if k == "token" || k == "client_secret" {
|
|
out[k] = maskSecret(v)
|
|
} else {
|
|
out[k] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
const minMaskSecretLength = 8
|
|
|
|
func maskSecret(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) <= minMaskSecretLength {
|
|
return "******"
|
|
}
|
|
return s[:4] + "..." + s[len(s)-4:]
|
|
}
|