mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 16:46:37 +08:00
refactor(msg_gateway): restructure and rename message_gateway aligned with custom_example
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"Wavelet/plugins/domain/msg_gateway/consts"
|
||||
"Wavelet/plugins/domain/msg_gateway/dao"
|
||||
"Wavelet/plugins/domain/msg_gateway/model/do"
|
||||
"Wavelet/plugins/domain/msg_gateway/model/entity"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tencent-connect/botgo/token"
|
||||
)
|
||||
|
||||
const defaultTelegramAPI = "https://api.telegram.org"
|
||||
|
||||
// ListDefinitions returns the admin form schema of every supported channel type.
|
||||
func ListDefinitions() []do.Definition {
|
||||
return []do.Definition{
|
||||
{
|
||||
Type: consts.MessageChannelTypeTelegram,
|
||||
Fields: []do.Field{
|
||||
{Key: "token", Type: consts.TypePassword, Required: true},
|
||||
{Key: "api_base", Type: consts.TypeText, Required: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: consts.MessageChannelTypeQQ,
|
||||
Fields: []do.Field{
|
||||
{Key: "app_id", Type: consts.TypeText, Required: true},
|
||||
{Key: "client_secret", Type: "password", Required: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CreateChannel validates the admin payload and persists an encrypted channel.
|
||||
func CreateChannel(ctx context.Context, req do.CreateChannelRequest) (do.ChannelDTO, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return do.ChannelDTO{}, errors.New(consts.ErrNameRequired)
|
||||
}
|
||||
channelType := strings.TrimSpace(req.Type)
|
||||
if channelType != consts.MessageChannelTypeTelegram && channelType != consts.MessageChannelTypeQQ {
|
||||
return do.ChannelDTO{}, errors.New(consts.ErrTypeInvalid)
|
||||
}
|
||||
creds := req.Credentials
|
||||
if creds == nil {
|
||||
creds = map[string]string{}
|
||||
}
|
||||
if err := ValidateCredentials(channelType, creds, false); err != nil {
|
||||
return do.ChannelDTO{}, err
|
||||
}
|
||||
cipher, err := EncryptCredentials(creds)
|
||||
if err != nil {
|
||||
return do.ChannelDTO{}, err
|
||||
}
|
||||
extra := req.Extra
|
||||
if extra == nil {
|
||||
extra = map[string]string{}
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
row := &entity.MessageChannel{
|
||||
Name: name,
|
||||
Type: channelType,
|
||||
OwnerScope: consts.MessageOwnerScopeSystem,
|
||||
Enabled: enabled,
|
||||
Credentials: cipher,
|
||||
Extra: EncodeExtra(extra),
|
||||
}
|
||||
if err := dao.CreateMessageChannel(ctx, row); err != nil {
|
||||
return do.ChannelDTO{}, err
|
||||
}
|
||||
return ToDTO(row, creds, extra), nil
|
||||
}
|
||||
|
||||
// UpdateChannel patches a channel; empty secrets keep the stored ciphertext.
|
||||
func UpdateChannel(ctx context.Context, id uint64, req do.UpdateChannelRequest) (do.ChannelDTO, error) {
|
||||
row, err := dao.GetMessageChannel(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, consts.ErrRecordNotFound) {
|
||||
return do.ChannelDTO{}, errors.New(consts.ErrChannelNotFound)
|
||||
}
|
||||
return do.ChannelDTO{}, err
|
||||
}
|
||||
creds, err := DecryptCredentials(row.Credentials)
|
||||
if err != nil {
|
||||
return do.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 do.ChannelDTO{}, err
|
||||
}
|
||||
creds = merged
|
||||
}
|
||||
|
||||
cipher, err := EncryptCredentials(creds)
|
||||
if err != nil {
|
||||
return do.ChannelDTO{}, err
|
||||
}
|
||||
row.Credentials = cipher
|
||||
row.Extra = EncodeExtra(extra)
|
||||
if err := dao.UpdateMessageChannel(ctx, row); err != nil {
|
||||
return do.ChannelDTO{}, err
|
||||
}
|
||||
return ToDTO(row, creds, extra), nil
|
||||
}
|
||||
|
||||
// ListChannels returns every channel with secrets masked.
|
||||
func ListChannels(ctx context.Context) ([]do.ChannelDTO, error) {
|
||||
rows, err := dao.ListMessageChannels(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]do.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
|
||||
}
|
||||
|
||||
// DeleteChannel removes a channel together with its bindings and pairing codes.
|
||||
func DeleteChannel(ctx context.Context, id uint64) error {
|
||||
if _, err := dao.GetMessageChannel(ctx, id); err != nil {
|
||||
if errors.Is(err, consts.ErrRecordNotFound) {
|
||||
return errors.New(consts.ErrChannelNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return dao.DeleteMessageChannel(ctx, id)
|
||||
}
|
||||
|
||||
// ProbeChannel verifies the stored credentials against the upstream platform.
|
||||
func ProbeChannel(ctx context.Context, id uint64) error {
|
||||
row, err := dao.GetMessageChannel(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, consts.ErrRecordNotFound) {
|
||||
return errors.New(consts.ErrChannelNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
creds, err := DecryptCredentials(row.Credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch row.Type {
|
||||
case consts.MessageChannelTypeTelegram:
|
||||
return ProbeTelegram(ctx, creds)
|
||||
case consts.MessageChannelTypeQQ:
|
||||
return ProbeQQ(ctx, creds)
|
||||
default:
|
||||
return errors.New(consts.ErrTypeInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
// ProbeTelegram calls getMe to confirm the bot token is usable.
|
||||
func ProbeTelegram(ctx context.Context, creds map[string]string) error {
|
||||
tok := creds["token"]
|
||||
if strings.TrimSpace(tok) == "" {
|
||||
return errors.New(consts.ErrMissingTelegramToken)
|
||||
}
|
||||
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("%s (%d): %s", consts.ErrTelegramGetMeFailed, 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("%s: %s", consts.ErrTelegramNotOK, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProbeQQ exchanges the app credentials for an access token.
|
||||
func ProbeQQ(_ context.Context, creds map[string]string) error {
|
||||
appID := strings.TrimSpace(creds["app_id"])
|
||||
secret := strings.TrimSpace(creds["client_secret"])
|
||||
if appID == "" || secret == "" {
|
||||
return errors.New(consts.ErrMissingQQCredentials)
|
||||
}
|
||||
credentials := &token.QQBotCredentials{
|
||||
AppID: appID,
|
||||
AppSecret: secret,
|
||||
}
|
||||
tokSrc := token.NewQQBotTokenSource(credentials)
|
||||
tok, err := tokSrc.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", consts.ErrQQTokenFetchFailed, err)
|
||||
}
|
||||
if tok == nil || tok.AccessToken == "" {
|
||||
return errors.New(consts.ErrQQEmptyToken)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCredentials checks the admin submitted credentials for a channel type.
|
||||
func ValidateCredentials(t string, creds map[string]string, isUpdate bool) error {
|
||||
switch t {
|
||||
case consts.MessageChannelTypeTelegram:
|
||||
tok := creds["token"]
|
||||
if strings.TrimSpace(tok) == "" && !isUpdate {
|
||||
return errors.New(consts.ErrTelegramTokenRequired)
|
||||
}
|
||||
if base, ok := creds["api_base"]; ok && strings.TrimSpace(base) != "" {
|
||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
return errors.New(consts.ErrAPIBaseInvalid)
|
||||
}
|
||||
}
|
||||
case consts.MessageChannelTypeQQ:
|
||||
appID := creds["app_id"]
|
||||
secret := creds["client_secret"]
|
||||
if (strings.TrimSpace(appID) == "" || strings.TrimSpace(secret) == "") && !isUpdate {
|
||||
return errors.New(consts.ErrQQCredentialsRequired)
|
||||
}
|
||||
default:
|
||||
return errors.New(consts.ErrTypeInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToDTO projects a channel row onto the admin DTO with credentials masked.
|
||||
func ToDTO(row *entity.MessageChannel, creds, extra map[string]string) do.ChannelDTO {
|
||||
return do.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,
|
||||
}
|
||||
}
|
||||
|
||||
// MaskCredentials hides secret bearing credential entries.
|
||||
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
|
||||
|
||||
// MaskSecret keeps only a short visible prefix and suffix of a secret.
|
||||
func MaskSecret(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= minMaskSecretLength {
|
||||
return "******"
|
||||
}
|
||||
return s[:4] + "..." + s[len(s)-4:]
|
||||
}
|
||||
Reference in New Issue
Block a user