mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
4f50f6a8f9
Wire plugin services through Bind/InjectFrom and AppContext so HTTP and workers resolve dependencies after Apply. Register TaskHandler objects with persisted results, and implement send_email_code, mail:send, cleanup_inactive_users, and dispatch_bot_msg.
311 lines
8.5 KiB
Go
311 lines
8.5 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package service
|
|
|
|
import (
|
|
"Wavelet/plugins/domain/message_gateway/errs"
|
|
"Wavelet/plugins/domain/message_gateway/model"
|
|
"Wavelet/plugins/domain/message_gateway/repository"
|
|
"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() []model.Definition {
|
|
return []model.Definition{
|
|
{
|
|
Type: model.MessageChannelTypeTelegram,
|
|
Fields: []model.Field{
|
|
{Key: "token", Type: model.TypePassword, Required: true},
|
|
{Key: "api_base", Type: model.TypeText, Required: false},
|
|
},
|
|
},
|
|
{
|
|
Type: model.MessageChannelTypeQQ,
|
|
Fields: []model.Field{
|
|
{Key: "app_id", Type: model.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 model.CreateChannelRequest) (model.ChannelDTO, error) {
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
return model.ChannelDTO{}, errors.New(errs.ErrNameRequired)
|
|
}
|
|
channelType := strings.TrimSpace(req.Type)
|
|
if channelType != model.MessageChannelTypeTelegram && channelType != model.MessageChannelTypeQQ {
|
|
return model.ChannelDTO{}, errors.New(errs.ErrTypeInvalid)
|
|
}
|
|
creds := req.Credentials
|
|
if creds == nil {
|
|
creds = map[string]string{}
|
|
}
|
|
if err := ValidateCredentials(channelType, creds, false); err != nil {
|
|
return model.ChannelDTO{}, err
|
|
}
|
|
cipher, err := EncryptCredentials(creds)
|
|
if err != nil {
|
|
return model.ChannelDTO{}, err
|
|
}
|
|
extra := req.Extra
|
|
if extra == nil {
|
|
extra = map[string]string{}
|
|
}
|
|
enabled := true
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
row := &model.MessageChannel{
|
|
Name: name,
|
|
Type: channelType,
|
|
OwnerScope: model.MessageOwnerScopeSystem,
|
|
Enabled: enabled,
|
|
Credentials: cipher,
|
|
Extra: EncodeExtra(extra),
|
|
}
|
|
if err := repository.CreateMessageChannel(ctx, row); err != nil {
|
|
return model.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 model.UpdateChannelRequest) (model.ChannelDTO, error) {
|
|
row, err := repository.GetMessageChannel(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, errs.ErrRecordNotFound) {
|
|
return model.ChannelDTO{}, errors.New(errs.ErrChannelNotFound)
|
|
}
|
|
return model.ChannelDTO{}, err
|
|
}
|
|
creds, err := DecryptCredentials(row.Credentials)
|
|
if err != nil {
|
|
return model.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 model.ChannelDTO{}, err
|
|
}
|
|
creds = merged
|
|
}
|
|
|
|
cipher, err := EncryptCredentials(creds)
|
|
if err != nil {
|
|
return model.ChannelDTO{}, err
|
|
}
|
|
row.Credentials = cipher
|
|
row.Extra = EncodeExtra(extra)
|
|
if err := repository.UpdateMessageChannel(ctx, row); err != nil {
|
|
return model.ChannelDTO{}, err
|
|
}
|
|
return ToDTO(row, creds, extra), nil
|
|
}
|
|
|
|
// ListChannels returns every channel with secrets masked.
|
|
func ListChannels(ctx context.Context) ([]model.ChannelDTO, error) {
|
|
rows, err := repository.ListMessageChannels(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]model.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 := repository.GetMessageChannel(ctx, id); err != nil {
|
|
if errors.Is(err, errs.ErrRecordNotFound) {
|
|
return errors.New(errs.ErrChannelNotFound)
|
|
}
|
|
return err
|
|
}
|
|
return repository.DeleteMessageChannel(ctx, id)
|
|
}
|
|
|
|
// ProbeChannel verifies the stored credentials against the upstream platform.
|
|
func ProbeChannel(ctx context.Context, id uint64) error {
|
|
row, err := repository.GetMessageChannel(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, errs.ErrRecordNotFound) {
|
|
return errors.New(errs.ErrChannelNotFound)
|
|
}
|
|
return err
|
|
}
|
|
creds, err := DecryptCredentials(row.Credentials)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch row.Type {
|
|
case model.MessageChannelTypeTelegram:
|
|
return ProbeTelegram(ctx, creds)
|
|
case model.MessageChannelTypeQQ:
|
|
return ProbeQQ(ctx, creds)
|
|
default:
|
|
return errors.New(errs.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(errs.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", errs.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", errs.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(errs.ErrMissingQQCredentials)
|
|
}
|
|
credentials := &token.QQBotCredentials{
|
|
AppID: appID,
|
|
AppSecret: secret,
|
|
}
|
|
tokSrc := token.NewQQBotTokenSource(credentials)
|
|
tok, err := tokSrc.Token()
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", errs.ErrQQTokenFetchFailed, err)
|
|
}
|
|
if tok == nil || tok.AccessToken == "" {
|
|
return errors.New(errs.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 model.MessageChannelTypeTelegram:
|
|
tok := creds["token"]
|
|
if strings.TrimSpace(tok) == "" && !isUpdate {
|
|
return errors.New(errs.ErrTelegramTokenRequired)
|
|
}
|
|
if base, ok := creds["api_base"]; ok && strings.TrimSpace(base) != "" {
|
|
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
|
return errors.New(errs.ErrAPIBaseInvalid)
|
|
}
|
|
}
|
|
case model.MessageChannelTypeQQ:
|
|
appID := creds["app_id"]
|
|
secret := creds["client_secret"]
|
|
if (strings.TrimSpace(appID) == "" || strings.TrimSpace(secret) == "") && !isUpdate {
|
|
return errors.New(errs.ErrQQCredentialsRequired)
|
|
}
|
|
default:
|
|
return errors.New(errs.ErrTypeInvalid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ToDTO projects a channel row onto the admin DTO with credentials masked.
|
|
func ToDTO(row *model.MessageChannel, creds, extra map[string]string) model.ChannelDTO {
|
|
return model.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:]
|
|
}
|