refactor(plugins): restructure admin and message_gateway into standard layered sub-packages

This commit is contained in:
ryan
2026-08-28 22:33:26 +08:00
parent 85b383a4e0
commit f4975d6732
128 changed files with 12050 additions and 9901 deletions
@@ -1,338 +0,0 @@
// 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:]
}
@@ -1,21 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import "context"
// Handler processes one inbound message.
type Handler func(ctx context.Context, msg InboundMessage) error
// Factory constructs a Channel from decrypted config.
type Factory func(cfg ChannelConfig, onInbound Handler) (Channel, error)
// Channel is one connected messaging adapter.
type Channel interface {
Type() string
Connect(ctx context.Context) error
Disconnect(ctx context.Context) error
Send(ctx context.Context, to Recipient, msg OutboundMessage) error
Capabilities() Capability
}
@@ -7,7 +7,8 @@ package qq
import (
"Wavelet/pkg/logger"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/message_gateway"
"Wavelet/plugins/domain/message_gateway/model"
"Wavelet/plugins/domain/message_gateway/service"
"context"
"fmt"
"strings"
@@ -32,8 +33,8 @@ type qqEvent struct {
// Adapter is an official QQ Bot C2C channel.
type Adapter struct {
cfg message_gateway.ChannelConfig
onInbound message_gateway.Handler
cfg model.ChannelConfig
onInbound service.Handler
api openapi.OpenAPI
tokenSrc oauth2.TokenSource
cancel context.CancelFunc
@@ -42,7 +43,7 @@ type Adapter struct {
}
// New constructs a QQ adapter.
func New(cfg message_gateway.ChannelConfig, onInbound message_gateway.Handler) (message_gateway.Channel, error) {
func New(cfg model.ChannelConfig, onInbound service.Handler) (service.Channel, error) {
if strings.TrimSpace(cfg.Credentials["app_id"]) == "" || strings.TrimSpace(cfg.Credentials["app_secret"]) == "" {
return nil, fmt.Errorf("qq: app_id and app_secret are required")
}
@@ -50,11 +51,11 @@ func New(cfg message_gateway.ChannelConfig, onInbound message_gateway.Handler) (
}
// Type returns qq.
func (a *Adapter) Type() string { return message_gateway.ChannelTypeQQ }
func (a *Adapter) Type() string { return model.ChannelTypeQQ }
// Capabilities reports C2C text/media support.
func (a *Adapter) Capabilities() message_gateway.Capability {
return message_gateway.Capability{Text: true, Image: true, File: true, Reply: true}
func (a *Adapter) Capabilities() model.Capability {
return model.Capability{Text: true, Image: true, File: true, Reply: true}
}
// Connect starts the official WebSocket session (C2C intent).
@@ -127,7 +128,7 @@ func (a *Adapter) Disconnect(_ context.Context) error {
}
// Send posts a C2C text reply.
func (a *Adapter) Send(ctx context.Context, to message_gateway.Recipient, msg message_gateway.OutboundMessage) error {
func (a *Adapter) Send(ctx context.Context, to model.Recipient, msg model.OutboundMessage) error {
a.mu.Lock()
api := a.api
a.mu.Unlock()
@@ -151,9 +152,9 @@ func (a *Adapter) handleEvent(ctx context.Context, ev qqEvent) {
if disconnected || a.onInbound == nil {
return
}
_ = a.onInbound(ctx, message_gateway.InboundMessage{
_ = a.onInbound(ctx, model.InboundMessage{
ChannelID: a.cfg.ID,
ChannelType: message_gateway.ChannelTypeQQ,
ChannelType: model.ChannelTypeQQ,
PlatformUserID: ev.UserID,
ChatID: ev.UserID,
MessageID: ev.MessageID,
@@ -4,14 +4,14 @@
package qq
import (
"Wavelet/plugins/domain/message_gateway"
"Wavelet/plugins/domain/message_gateway/model"
"context"
"testing"
)
func TestHandleEvent_DropsNonC2C(t *testing.T) {
var got int
a := &Adapter{onInbound: func(ctx context.Context, msg message_gateway.InboundMessage) error {
a := &Adapter{onInbound: func(ctx context.Context, msg model.InboundMessage) error {
got++
return nil
}}
@@ -22,8 +22,8 @@ func TestHandleEvent_DropsNonC2C(t *testing.T) {
}
func TestHandleEvent_C2CText(t *testing.T) {
var got message_gateway.InboundMessage
a := &Adapter{cfg: message_gateway.ChannelConfig{ID: 3}, onInbound: func(ctx context.Context, msg message_gateway.InboundMessage) error {
var got model.InboundMessage
a := &Adapter{cfg: model.ChannelConfig{ID: 3}, onInbound: func(ctx context.Context, msg model.InboundMessage) error {
got = msg
return nil
}}
@@ -34,7 +34,7 @@ func TestHandleEvent_C2CText(t *testing.T) {
}
func TestNew_RequiresCreds(t *testing.T) {
_, err := New(message_gateway.ChannelConfig{}, nil)
_, err := New(model.ChannelConfig{}, nil)
if err == nil {
t.Fatal("expected error")
}
@@ -6,7 +6,8 @@ package telegram
import (
"Wavelet/pkg/util"
"Wavelet/plugins/domain/message_gateway"
"Wavelet/plugins/domain/message_gateway/model"
"Wavelet/plugins/domain/message_gateway/service"
"context"
"fmt"
"os"
@@ -19,13 +20,13 @@ import (
// Adapter is a Telegram private-chat channel.
type Adapter struct {
cfg message_gateway.ChannelConfig
onInbound message_gateway.Handler
cfg model.ChannelConfig
onInbound service.Handler
bot *tele.Bot
}
// New constructs a Telegram adapter. Call message_gateway.Register from the runner.
func New(cfg message_gateway.ChannelConfig, onInbound message_gateway.Handler) (message_gateway.Channel, error) {
// New constructs a Telegram adapter. Call service.Register from the runner.
func New(cfg model.ChannelConfig, onInbound service.Handler) (service.Channel, error) {
if strings.TrimSpace(cfg.Credentials["bot_token"]) == "" {
return nil, fmt.Errorf("telegram: bot_token is required")
}
@@ -33,11 +34,11 @@ func New(cfg message_gateway.ChannelConfig, onInbound message_gateway.Handler) (
}
// Type returns telegram.
func (a *Adapter) Type() string { return message_gateway.ChannelTypeTelegram }
func (a *Adapter) Type() string { return model.ChannelTypeTelegram }
// Capabilities reports private-chat media support.
func (a *Adapter) Capabilities() message_gateway.Capability {
return message_gateway.Capability{Text: true, Image: true, File: true, Reply: true}
func (a *Adapter) Capabilities() model.Capability {
return model.Capability{Text: true, Image: true, File: true, Reply: true}
}
// Connect starts long polling.
@@ -85,7 +86,7 @@ func (a *Adapter) Disconnect(_ context.Context) error {
}
// Send replies to a private chat.
func (a *Adapter) Send(_ context.Context, to message_gateway.Recipient, msg message_gateway.OutboundMessage) error {
func (a *Adapter) Send(_ context.Context, to model.Recipient, msg model.OutboundMessage) error {
if a.bot == nil {
return fmt.Errorf("telegram: not connected")
}
@@ -104,9 +105,9 @@ func (a *Adapter) handleTeleMessage(ctx context.Context, m *tele.Message) {
if a.onInbound == nil {
return
}
msg := message_gateway.InboundMessage{
msg := model.InboundMessage{
ChannelID: a.cfg.ID,
ChannelType: message_gateway.ChannelTypeTelegram,
ChannelType: model.ChannelTypeTelegram,
PlatformUserID: strconv.FormatInt(m.Sender.ID, 10),
ChatID: strconv.FormatInt(m.Chat.ID, 10),
MessageID: strconv.Itoa(m.ID),
@@ -121,7 +122,7 @@ func (a *Adapter) handleTeleMessage(ctx context.Context, m *tele.Message) {
_ = a.onInbound(ctx, msg)
}
func (a *Adapter) downloadMedia(m *tele.Message) []message_gateway.Attachment {
func (a *Adapter) downloadMedia(m *tele.Message) []model.Attachment {
var files []*tele.File
var names []string
if m.Photo != nil {
@@ -141,16 +142,16 @@ func (a *Adapter) downloadMedia(m *tele.Message) []message_gateway.Attachment {
}
dir, err := os.MkdirTemp("", "wg-tg-*")
if err != nil {
return []message_gateway.Attachment{{Error: err.Error()}}
return []model.Attachment{{Error: err.Error()}}
}
out := make([]message_gateway.Attachment, 0, len(files))
out := make([]model.Attachment, 0, len(files))
for i, f := range files {
path := filepath.Join(dir, names[i])
if err := a.bot.Download(f, path); err != nil {
out = append(out, message_gateway.Attachment{FileName: names[i], Error: err.Error()})
out = append(out, model.Attachment{FileName: names[i], Error: err.Error()})
continue
}
out = append(out, message_gateway.Attachment{Path: path, FileName: names[i]})
out = append(out, model.Attachment{Path: path, FileName: names[i]})
}
return out
}
@@ -4,7 +4,7 @@
package telegram
import (
"Wavelet/plugins/domain/message_gateway"
"Wavelet/plugins/domain/message_gateway/model"
"context"
"testing"
@@ -13,7 +13,7 @@ import (
func TestHandleUpdate_DropsGroups(t *testing.T) {
var got int
a := &Adapter{onInbound: func(ctx context.Context, msg message_gateway.InboundMessage) error {
a := &Adapter{onInbound: func(ctx context.Context, msg model.InboundMessage) error {
got++
return nil
}}
@@ -29,10 +29,10 @@ func TestHandleUpdate_DropsGroups(t *testing.T) {
}
func TestHandleUpdate_PrivateText(t *testing.T) {
var got message_gateway.InboundMessage
var got model.InboundMessage
a := &Adapter{
cfg: message_gateway.ChannelConfig{ID: 7, Type: "telegram"},
onInbound: func(ctx context.Context, msg message_gateway.InboundMessage) error {
cfg: model.ChannelConfig{ID: 7, Type: "telegram"},
onInbound: func(ctx context.Context, msg model.InboundMessage) error {
got = msg
return nil
},
@@ -49,7 +49,7 @@ func TestHandleUpdate_PrivateText(t *testing.T) {
}
func TestNew_RequiresToken(t *testing.T) {
_, err := New(message_gateway.ChannelConfig{}, nil)
_, err := New(model.ChannelConfig{}, nil)
if err == nil {
t.Fatal("expected error")
}
@@ -1,41 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import (
"Wavelet/core/contracts"
"context"
"time"
)
// AdminLogin is the metadata definition for the admin login event.
var AdminLogin = EventMetadata{
Key: "admin_login",
Name: "管理员登录",
DefaultTemplate: NotificationMessage{
Title: "管理员登录提醒",
Content: "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。",
Level: "INFO",
},
Description: "当管理员成功登录系统时触发此通知",
}
// HandleAdminLoggedIn 处理管理员登录事件并触发通知
func HandleAdminLoggedIn(ctx context.Context, event contracts.AdminLoggedIn) {
if event.User == nil {
return
}
body := map[string]any{
"user": event.User,
"ip": event.IP,
"time": time.Now().Format("2006-01-02 15:04:05"),
}
DefaultTrigger.Trigger(ctx, AdminLogin, body)
}
// RegisterCustomEvents registers default domain push notification events.
func RegisterCustomEvents() {
RegisterBuiltInEvent(AdminLogin)
}
@@ -1,97 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import (
"Wavelet/core"
"Wavelet/core/contracts"
"context"
"sync"
"gorm.io/gorm"
)
var (
dbMu sync.RWMutex
dbSvc contracts.DBService
cacheMu sync.RWMutex
cacheSvc contracts.CacheService
taskMu sync.RWMutex
taskSvc contracts.TaskService
userMu sync.RWMutex
userSvc contracts.UserService
)
// SetDBServiceForTest injects a DBService for tests. Production wiring must use Apply.
func SetDBServiceForTest(s contracts.DBService) {
setDBService(s)
}
func setDBService(s contracts.DBService) {
dbMu.Lock()
defer dbMu.Unlock()
dbSvc = s
}
func setCacheService(s contracts.CacheService) {
cacheMu.Lock()
defer cacheMu.Unlock()
cacheSvc = s
}
func setTaskService(s contracts.TaskService) {
taskMu.Lock()
defer taskMu.Unlock()
taskSvc = s
}
func setUserService(s contracts.UserService) {
userMu.Lock()
defer userMu.Unlock()
userSvc = s
}
func getDB(ctx context.Context) *gorm.DB {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil {
return s.DB(ctx)
}
}
dbMu.RLock()
s := dbSvc
dbMu.RUnlock()
if s != nil {
return s.DB(ctx)
}
return nil
}
func getCache(ctx context.Context) contracts.CacheService {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.CacheService](c); err == nil && s != nil {
return s
}
}
cacheMu.RLock()
s := cacheSvc
cacheMu.RUnlock()
return s
}
func getTaskService() contracts.TaskService {
taskMu.RLock()
defer taskMu.RUnlock()
return taskSvc
}
func getUserService(ctx context.Context) contracts.UserService {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.UserService](c); err == nil && s != nil {
return s
}
}
userMu.RLock()
defer userMu.RUnlock()
return userSvc
}
@@ -1,26 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import "errors"
var (
errCodeInvalid = errors.New("invalid or expired pairing code")
errChannelMismatch = errors.New("pairing code does not match channel")
errPlatformAlreadyBound = errors.New("this platform account is already bound")
errBindingNotFound = errors.New("binding not found")
errBindingForbidden = errors.New("cannot unbind another user's binding")
errChannelIDRequired = errors.New("channel_id is required")
errChannelDisabled = errors.New("channel is not enabled")
)
const (
errNameRequired = "name is required"
errTypeInvalid = "type must be telegram or qq"
errTelegramTokenRequired = "telegram bot secret is required" //nolint:gosec // user-facing validation text
errQQCredentialsRequired = "qq app id and secret are required" //nolint:gosec // user-facing validation text
errChannelNotFound = "channel not found"
errChannelProbeFailed = "channel probe failed"
maskedSecret = "********"
)
@@ -0,0 +1,68 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package errs defines error sentinels and user-facing error message constants
// for the message_gateway plugin.
package errs
import "errors"
// Sentinel errors.
var (
ErrCodeInvalid = errors.New("invalid or expired pairing code")
ErrChannelMismatch = errors.New("pairing code does not match channel")
ErrPlatformAlreadyBound = errors.New("this platform account is already bound")
ErrBindingNotFound = errors.New("binding not found")
ErrBindingForbidden = errors.New("cannot unbind another user's binding")
ErrChannelIDRequired = errors.New("channel_id is required")
ErrChannelDisabled = errors.New("channel is not enabled")
// ErrRecordNotFound maps GORM's missing-row sentinel at the repository boundary so
// upper layers never import gorm. Its text matches gorm.ErrRecordNotFound verbatim.
ErrRecordNotFound = errors.New("record not found")
)
// User-facing validation and error message constants.
const (
ErrNameRequired = "name is required"
ErrTypeInvalid = "type must be telegram or qq"
ErrTelegramTokenRequired = "telegram bot secret is required" //nolint:gosec // user-facing validation text
ErrQQCredentialsRequired = "qq app id and secret are required" //nolint:gosec // user-facing validation text
ErrChannelNotFound = "channel not found"
ErrChannelProbeFailed = "channel probe failed"
MaskedSecret = "********"
ErrLoginRequired = "login required"
ErrInvalidBindingID = "invalid binding id"
ErrInvalidChannelID = "invalid channel id"
ErrInvalidEventID = "invalid event id"
ErrEventNotFound = "notification event not found"
ErrValidationFailed = "validation failed"
ErrMissingTelegramToken = "missing telegram bot token"
ErrMissingQQCredentials = "missing qq app_id or app_secret" //nolint:gosec // user-facing validation text
ErrQQTokenFetchFailed = "qq token fetch failed" //nolint:gosec // user-facing validation text
ErrQQEmptyToken = "qq returned empty access token" //nolint:gosec // user-facing validation text
ErrTelegramGetMeFailed = "telegram getMe failed"
ErrTelegramNotOK = "telegram returned ok=false"
ErrAPIBaseInvalid = "api_base must start with http:// or https://"
ErrChannelNameExists = "channel name already exists"
ErrChannelNameRequired = "channel name is required"
ErrChannelTypeRequired = "channel type is required"
ErrEventKeyRequired = "event_key is required"
ErrEventAlreadyConfigured = "this notification event is already configured"
ErrTemplateInvalidJSON = "custom template is not a valid JSON format"
ErrEnableWithoutChannels = "cannot enable event without any push channels configured"
ErrEventKeyOrTaskType = "either event_key or task_type must be provided"
ErrUnsupportedEventKey = "unsupported built-in event key"
ErrTaskServiceUnavailable = "task service not available"
ErrUserNotFound = "user not found"
ErrNoAdminUser = "no admin user found"
ErrPayloadRequired = "payload is required"
ErrInvalidJSONFormat = "invalid json format"
ErrParsePayloadFailed = "parse payload failed"
ErrGetPusherFailed = "get pusher failed"
ErrPusherSendFailed = "pusher.Send failed"
)
@@ -1,62 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package message_gateway defines channel adapters, pairing codes, and inbound types.
package message_gateway
// ChannelTypeTelegram is the Telegram private-chat adapter type.
const ChannelTypeTelegram = "telegram"
// ChannelTypeQQ is the official QQ Bot C2C adapter type.
const ChannelTypeQQ = "qq"
// Capability describes what an adapter can send and receive.
type Capability struct {
Text bool
Image bool
File bool
Reply bool
Group bool
}
// ChannelConfig is the decrypted runtime config passed to a factory.
type ChannelConfig struct {
ID uint64
Type string
Name string
Credentials map[string]string
Extra map[string]string
}
// Recipient is the outbound destination on a platform.
type Recipient struct {
ChatID string
PlatformUserID string
}
// Attachment is a downloaded inbound file sitting on local disk.
type Attachment struct {
Path string
FileName string
MIME string
Error string
}
// InboundMessage is a normalized private-chat message.
type InboundMessage struct {
ChannelID uint64
ChannelType string
PlatformUserID string
ChatID string
MessageID string
Text string
Attachments []Attachment
BindingUserID *uint64
}
// OutboundMessage is a reply or probe send.
type OutboundMessage struct {
Text string
ReplyToID string
Attachments []Attachment
}
@@ -1,10 +1,12 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
package handler
import (
"Wavelet/pkg/response"
"Wavelet/plugins/domain/message_gateway/errs"
"Wavelet/plugins/domain/message_gateway/service"
"net/http"
"strconv"
@@ -17,10 +19,10 @@ import (
// @Tags admin-message-gateway
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]Definition}
// @Success 200 {object} response.Any{data=[]model.Definition}
// @Router /api/v1/admin/message-gateway/channels/definitions [get]
func ListAdminChannelDefinitions(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(listDefinitions()))
c.JSON(http.StatusOK, response.OK(service.ListDefinitions()))
}
// ListAdminChannels lists configured messaging channels with secrets masked.
@@ -29,10 +31,10 @@ func ListAdminChannelDefinitions(c *gin.Context) {
// @Tags admin-message-gateway
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]ChannelDTO}
// @Success 200 {object} response.Any{data=[]model.ChannelDTO}
// @Router /api/v1/admin/message-gateway/channels [get]
func ListAdminChannels(c *gin.Context) {
rows, err := listChannels(c.Request.Context())
rows, err := service.ListChannels(c.Request.Context())
if err != nil {
response.AbortInternal(c, err.Error())
return
@@ -43,14 +45,14 @@ func ListAdminChannels(c *gin.Context) {
func parseAdminChannelID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortBadRequest(c, "invalid channel id")
response.AbortBadRequest(c, errs.ErrInvalidChannelID)
return 0, false
}
return id, true
}
func handleAdminChannelError(c *gin.Context, err error, fallback func(c *gin.Context, msg string)) {
if err.Error() == errChannelNotFound {
if err.Error() == errs.ErrChannelNotFound {
response.AbortNotFound(c, err.Error())
return
}
@@ -64,12 +66,12 @@ func handleAdminChannelError(c *gin.Context, err error, fallback func(c *gin.Con
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body CreateChannelRequest true "create body"
// @Success 200 {object} response.Any{data=ChannelDTO}
// @Param request body model.CreateChannelRequest true "create body"
// @Success 200 {object} response.Any{data=model.ChannelDTO}
// @Failure 400 {object} response.Any
// @Router /api/v1/admin/message-gateway/channels [post]
func CreateAdminChannel(c *gin.Context) {
handleJSONRequest(c, createChannel)
handleJSONRequest(c, service.CreateChannel)
}
// UpdateAdminChannel patches a messaging channel. Empty secrets keep the previous values.
@@ -80,13 +82,13 @@ func CreateAdminChannel(c *gin.Context) {
// @Produce json
// @Security SessionCookie
// @Param id path int true "channel id"
// @Param request body UpdateChannelRequest true "update body"
// @Success 200 {object} response.Any{data=ChannelDTO}
// @Param request body model.UpdateChannelRequest true "update body"
// @Success 200 {object} response.Any{data=model.ChannelDTO}
// @Failure 400 {object} response.Any
// @Failure 404 {object} response.Any
// @Router /api/v1/admin/message-gateway/channels/{id} [patch]
func UpdateAdminChannel(c *gin.Context) {
handleEntityUpdate(c, parseAdminChannelID, updateChannel, func(c *gin.Context, err error) {
handleEntityUpdate(c, parseAdminChannelID, service.UpdateChannel, func(c *gin.Context, err error) {
handleAdminChannelError(c, err, response.AbortBadRequest)
})
}
@@ -106,7 +108,7 @@ func DeleteAdminChannel(c *gin.Context) {
if !ok {
return
}
if err := deleteChannel(c.Request.Context(), id); err != nil {
if err := service.DeleteChannel(c.Request.Context(), id); err != nil {
handleAdminChannelError(c, err, response.AbortInternal)
return
}
@@ -129,22 +131,9 @@ func TestAdminChannel(c *gin.Context) {
if !ok {
return
}
if err := probeChannel(c.Request.Context(), id); err != nil {
if err := service.ProbeChannel(c.Request.Context(), id); err != nil {
handleAdminChannelError(c, err, response.AbortBadRequest)
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// RegisterAdminRoutes mounts admin message-gateway APIs under /admin.
func RegisterAdminRoutes(adminRouter *gin.RouterGroup) {
g := adminRouter.Group("/message-gateway")
{
g.GET("/channels/definitions", ListAdminChannelDefinitions)
g.GET("/channels", ListAdminChannels)
g.POST("/channels", CreateAdminChannel)
g.PATCH("/channels/:id", UpdateAdminChannel)
g.DELETE("/channels/:id", DeleteAdminChannel)
g.POST("/channels/:id/test", TestAdminChannel)
}
}
@@ -1,12 +1,17 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
// Package handler provides HTTP endpoints for message_gateway.
package handler
import (
"Wavelet/core/contracts"
"Wavelet/pkg/ginutil"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/message_gateway/errs"
"Wavelet/plugins/domain/message_gateway/model"
"Wavelet/plugins/domain/message_gateway/service"
"context"
"errors"
"net/http"
"strconv"
@@ -18,21 +23,62 @@ func currentUser(c *gin.Context) (*contracts.UserDTO, bool) {
return ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey)
}
// handleJSONRequest binds a JSON body, runs the service use case and writes the
// standard success envelope; any service error surfaces as a bad request.
func handleJSONRequest[Req any, Res any](c *gin.Context, handler func(ctx context.Context, req Req) (Res, error)) {
var req Req
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
res, err := handler(c.Request.Context(), req)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(res))
}
// handleEntityUpdate resolves a path identifier plus JSON body, runs the updater
// use case and writes the success envelope; error classification is delegated to onErr.
func handleEntityUpdate[Req any, Res any](
c *gin.Context,
parseID func(*gin.Context) (uint64, bool),
updater func(ctx context.Context, id uint64, req Req) (Res, error),
onErr func(*gin.Context, error),
) {
id, ok := parseID(c)
if !ok {
return
}
var req Req
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
dto, err := updater(c.Request.Context(), id, req)
if err != nil {
onErr(c, err)
return
}
c.JSON(http.StatusOK, response.OK(dto))
}
// ListChannels lists enabled channels a user can bind.
// @Summary List enabled messaging channels
// @Description Returns enabled system bots the current user can pair with
// @Tags message-gateway
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]PublicChannelDTO}
// @Success 200 {object} response.Any{data=[]model.PublicChannelDTO}
// @Failure 401 {object} response.Any
// @Router /api/v1/message-gateway/channels [get]
func ListChannels(c *gin.Context) {
if user, ok := currentUser(c); !ok || user == nil {
response.AbortUnauthorized(c, "login required")
response.AbortUnauthorized(c, errs.ErrLoginRequired)
return
}
rows, err := listEnabledPublicChannels(c.Request.Context())
rows, err := service.ListEnabledPublicChannels(c.Request.Context())
if err != nil {
response.AbortInternal(c, err.Error())
return
@@ -46,16 +92,16 @@ func ListChannels(c *gin.Context) {
// @Tags message-gateway
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]BindingDTO}
// @Success 200 {object} response.Any{data=[]model.BindingDTO}
// @Failure 401 {object} response.Any
// @Router /api/v1/message-gateway/bindings [get]
func ListBindings(c *gin.Context) {
user, ok := currentUser(c)
if !ok || user == nil {
response.AbortUnauthorized(c, "login required")
response.AbortUnauthorized(c, errs.ErrLoginRequired)
return
}
rows, err := listUserBindings(c.Request.Context(), user.ID)
rows, err := service.ListUserBindings(c.Request.Context(), user.ID)
if err != nil {
response.AbortInternal(c, err.Error())
return
@@ -70,25 +116,25 @@ func ListBindings(c *gin.Context) {
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body BindRequest true "bind body"
// @Success 200 {object} response.Any{data=BindingDTO}
// @Param request body model.BindRequest true "bind body"
// @Success 200 {object} response.Any{data=model.BindingDTO}
// @Failure 400 {object} response.Any
// @Failure 409 {object} response.Any
// @Router /api/v1/message-gateway/bindings [post]
func BindBinding(c *gin.Context) {
user, ok := currentUser(c)
if !ok || user == nil {
response.AbortUnauthorized(c, "login required")
response.AbortUnauthorized(c, errs.ErrLoginRequired)
return
}
var req BindRequest
var req model.BindRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
dto, err := bindChannel(c.Request.Context(), user.ID, req)
dto, err := service.BindChannel(c.Request.Context(), user.ID, req)
if err != nil {
if errors.Is(err, errPlatformAlreadyBound) {
if errors.Is(err, errs.ErrPlatformAlreadyBound) {
response.AbortConflict(c, err.Error())
return
}
@@ -112,20 +158,20 @@ func BindBinding(c *gin.Context) {
func UnbindBinding(c *gin.Context) {
user, ok := currentUser(c)
if !ok || user == nil {
response.AbortUnauthorized(c, "login required")
response.AbortUnauthorized(c, errs.ErrLoginRequired)
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortBadRequest(c, "invalid binding id")
response.AbortBadRequest(c, errs.ErrInvalidBindingID)
return
}
if err := unbindChannel(c.Request.Context(), user.ID, id); err != nil {
if errors.Is(err, errBindingNotFound) {
if err := service.UnbindChannel(c.Request.Context(), user.ID, id); err != nil {
if errors.Is(err, errs.ErrBindingNotFound) {
response.AbortNotFound(c, err.Error())
return
}
if errors.Is(err, errBindingForbidden) {
if errors.Is(err, errs.ErrBindingForbidden) {
response.AbortForbidden(c, err.Error())
return
}
@@ -134,14 +180,3 @@ func UnbindBinding(c *gin.Context) {
}
c.JSON(http.StatusOK, response.OKNil())
}
// RegisterUserRoutes mounts user-facing message gateway endpoints.
func RegisterUserRoutes(r *gin.RouterGroup, loginMW gin.HandlerFunc) {
mg := r.Group("/message-gateway", loginMW)
{
mg.GET("/channels", ListChannels)
mg.GET("/bindings", ListBindings)
mg.POST("/bindings", BindBinding)
mg.DELETE("/bindings/:id", UnbindBinding)
}
}
@@ -0,0 +1,96 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/pkg/response"
"Wavelet/plugins/domain/message_gateway/errs"
"Wavelet/plugins/domain/message_gateway/model"
"Wavelet/plugins/domain/message_gateway/service"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// ListPushChannelDefinitions returns channel definitions.
func ListPushChannelDefinitions(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(model.ListPushDefinitions()))
}
// ListPushChannels lists configured push channels.
func ListPushChannels(c *gin.Context) {
channels, err := service.ListPushChannels(c.Request.Context())
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(channels))
}
// parsePushChannelID reads the path identifier of a push channel.
func parsePushChannelID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortBadRequest(c, errs.ErrInvalidChannelID)
return 0, false
}
return id, true
}
// handlePushChannelNotFoundError maps a missing channel row to 404, others to fallback.
func handlePushChannelNotFoundError(c *gin.Context, err error, fallback func(c *gin.Context, msg string)) {
if errors.Is(err, errs.ErrRecordNotFound) {
response.AbortNotFound(c, errs.ErrChannelNotFound)
return
}
fallback(c, err.Error())
}
// CreatePushChannel creates a push channel.
func CreatePushChannel(c *gin.Context) {
handleJSONRequest(c, service.CreatePushChannel)
}
// UpdatePushChannel updates a push channel.
func UpdatePushChannel(c *gin.Context) {
handleEntityUpdate(c, parsePushChannelID, service.UpdatePushChannel, func(c *gin.Context, err error) {
handlePushChannelNotFoundError(c, err, response.AbortInternal)
})
}
// DeletePushChannel deletes a push channel.
func DeletePushChannel(c *gin.Context) {
id, ok := parsePushChannelID(c)
if !ok {
return
}
if err := service.DeletePushChannel(c.Request.Context(), id); err != nil {
handlePushChannelNotFoundError(c, err, response.AbortInternal)
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// TestPushChannel tests connectivity of a push channel.
func TestPushChannel(c *gin.Context) {
var req model.TestPushChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
payload, err := service.PreparePushChannelTest(c.Request.Context(), req)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if err := service.EnqueuePushTask(c.Request.Context(), payload); err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OKNil())
}
@@ -1,49 +1,24 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
package handler
import (
"Wavelet/pkg/response"
"Wavelet/plugins/domain/message_gateway/errs"
"Wavelet/plugins/domain/message_gateway/model"
"Wavelet/plugins/domain/message_gateway/service"
"errors"
"fmt"
"net/http"
"strconv"
pkgpush "Wavelet/plugins/domain/message_gateway/push"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// UpdatePushEventRequest is the request body for updating a push event.
type UpdatePushEventRequest struct {
Channels []string `json:"channels"`
Targets []string `json:"targets"`
Template string `json:"template" binding:"required"`
Enabled bool `json:"enabled"`
}
// CreatePushEventRequest is the request body for creating a push event.
type CreatePushEventRequest struct {
EventKey string `json:"event_key"`
TaskType string `json:"task_type"`
Channels []string `json:"channels"`
Targets []string `json:"targets"`
Template string `json:"template"`
Enabled bool `json:"enabled"`
}
// TestPushRequest is the request body for testing push config.
type TestPushRequest struct {
Config pkgpush.Config `json:"config" binding:"required"`
Target string `json:"target"`
}
// ListPushEvents lists configured push events.
func ListPushEvents(c *gin.Context) {
ctx := c.Request.Context()
events, err := listPushEvents(ctx)
events, err := service.ListPushEvents(ctx)
if err != nil {
response.AbortInternal(c, err.Error())
return
@@ -53,21 +28,23 @@ func ListPushEvents(c *gin.Context) {
// ListBuiltInPushEvents lists system built-in push event definitions.
func ListBuiltInPushEvents(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(GetBuiltInEvents()))
c.JSON(http.StatusOK, response.OK(service.GetBuiltInEvents()))
}
// parsePushEventID reads the path identifier of a push event.
func parsePushEventID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortBadRequest(c, "invalid event id")
response.AbortBadRequest(c, errs.ErrInvalidEventID)
return 0, false
}
return id, true
}
// handlePushEventNotFoundError maps a missing event row to 404, others to fallback.
func handlePushEventNotFoundError(c *gin.Context, err error, fallback func(c *gin.Context, msg string)) {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.AbortNotFound(c, "notification event not found")
if errors.Is(err, errs.ErrRecordNotFound) {
response.AbortNotFound(c, errs.ErrEventNotFound)
return
}
fallback(c, err.Error())
@@ -75,7 +52,7 @@ func handlePushEventNotFoundError(c *gin.Context, err error, fallback func(c *gi
// CreatePushEvent creates a new push event configuration.
func CreatePushEvent(c *gin.Context) {
handleJSONRequest(c, createPushEvent)
handleJSONRequest(c, service.CreatePushEvent)
}
// DeletePushEvent deletes a push event configuration by ID.
@@ -85,7 +62,7 @@ func DeletePushEvent(c *gin.Context) {
return
}
if err := deletePushEvent(c.Request.Context(), id); err != nil {
if err := service.DeletePushEvent(c.Request.Context(), id); err != nil {
handlePushEventNotFoundError(c, err, response.AbortInternal)
return
}
@@ -99,13 +76,13 @@ func UpdatePushEvent(c *gin.Context) {
return
}
var req UpdatePushEventRequest
var req model.UpdatePushEventRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if err := updatePushEvent(c.Request.Context(), id, req); err != nil {
if err := service.UpdatePushEvent(c.Request.Context(), id, req); err != nil {
handlePushEventNotFoundError(c, err, response.AbortBadRequest)
return
}
@@ -119,7 +96,7 @@ func TogglePushEvent(c *gin.Context) {
return
}
enabled, err := togglePushEvent(c.Request.Context(), id)
enabled, err := service.TogglePushEvent(c.Request.Context(), id)
if err != nil {
handlePushEventNotFoundError(c, err, response.AbortBadRequest)
return
@@ -138,7 +115,7 @@ func ListPushHistories(c *gin.Context) {
pageSize = 20
}
total, results, err := listPushHistories(c.Request.Context(), PushHistoryListFilter{
total, results, err := service.ListPushHistories(c.Request.Context(), model.PushHistoryListFilter{
EventKey: c.Query("event_key"),
Status: c.Query("status"),
Page: page,
@@ -157,30 +134,13 @@ func ListPushHistories(c *gin.Context) {
// TestPush executes a synchronous push test using the specified config.
func TestPush(c *gin.Context) {
var req TestPushRequest
var req model.TestPushRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
pusher, err := pkgpush.GetPusher(req.Config.Channel)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if err := pusher.ValidateConfig(req.Config); err != nil {
response.AbortBadRequest(c, fmt.Sprintf("validation failed: %v", err))
return
}
applySMTPFallbackToPushConfig(c.Request.Context(), &req.Config)
testBody := map[string]any{
keyTitle: "测试通道推送",
keyContent: "当您收到这条消息,说明当前渠道连通性测试通过。",
keyLevel: defaultLevelInfo,
}
if _, err := pusher.Send(c.Request.Context(), req.Config, req.Target, testBody, "", nil); err != nil {
if err := service.RunPushTest(c.Request.Context(), req.Config, req.Target); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
@@ -0,0 +1,63 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/core/extpoints"
"github.com/gin-gonic/gin"
)
// RegisterUserRoutes mounts user-facing message gateway endpoints.
func RegisterUserRoutes(r extpoints.RouterExtension, loginMW gin.HandlerFunc) {
mg := r.Group("/message-gateway", loginMW)
{
mg.GET("/channels", ListChannels)
mg.GET("/bindings", ListBindings)
mg.POST("/bindings", BindBinding)
mg.DELETE("/bindings/:id", UnbindBinding)
}
}
// RegisterAdminRoutes mounts admin message-gateway APIs under /admin.
func RegisterAdminRoutes(adminRouter extpoints.RouterExtension, loginMW, adminMW gin.HandlerFunc) {
g := adminRouter.Group("/message-gateway", loginMW, adminMW)
{
g.GET("/channels/definitions", ListAdminChannelDefinitions)
g.GET("/channels", ListAdminChannels)
g.POST("/channels", CreateAdminChannel)
g.PATCH("/channels/:id", UpdateAdminChannel)
g.DELETE("/channels/:id", DeleteAdminChannel)
g.POST("/channels/:id/test", TestAdminChannel)
}
}
// RegisterAdminPushRoutes mounts admin push notification APIs under /admin.
func RegisterAdminPushRoutes(adminRouter extpoints.RouterExtension, loginMW, adminMW gin.HandlerFunc) {
adminPushGroup := adminRouter.Group("/push", loginMW, adminMW)
{
events := adminPushGroup.Group("/events")
{
events.GET("", ListPushEvents)
events.GET("/builtin", ListBuiltInPushEvents)
events.POST("", CreatePushEvent)
events.PUT("/:id", UpdatePushEvent)
events.DELETE("/:id", DeletePushEvent)
events.POST("/:id/toggle", TogglePushEvent)
}
adminPushGroup.GET("/histories", ListPushHistories)
adminPushGroup.POST("/test", TestPush)
channels := adminPushGroup.Group("/channels")
{
channels.GET("/definitions", ListPushChannelDefinitions)
channels.GET("", ListPushChannels)
channels.POST("", CreatePushChannel)
channels.PUT("/:id", UpdatePushChannel)
channels.DELETE("/:id", DeletePushChannel)
channels.POST("/test", TestPushChannel)
}
}
}
@@ -1,49 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import (
"Wavelet/pkg/response"
"context"
"net/http"
"github.com/gin-gonic/gin"
)
func handleJSONRequest[Req any, Res any](c *gin.Context, handler func(ctx context.Context, req Req) (Res, error)) {
var req Req
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
res, err := handler(c.Request.Context(), req)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(res))
}
func handleEntityUpdate[Req any, Res any](
c *gin.Context,
parseID func(*gin.Context) (uint64, bool),
updater func(ctx context.Context, id uint64, req Req) (Res, error),
onErr func(*gin.Context, error),
) {
id, ok := parseID(c)
if !ok {
return
}
var req Req
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
dto, err := updater(c.Request.Context(), id, req)
if err != nil {
onErr(c, err)
return
}
c.JSON(http.StatusOK, response.OK(dto))
}
@@ -1,155 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import (
"context"
"errors"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
// BindRequest is the user bind body.
type BindRequest struct {
ChannelID string `json:"channel_id"`
Code string `json:"code"`
}
// BindingDTO is a user-facing binding row.
type BindingDTO struct {
ID uint64 `json:"id,string"`
UserID uint64 `json:"user_id,string"`
ChannelID uint64 `json:"channel_id,string"`
ChannelName string `json:"channel_name"`
ChannelType string `json:"channel_type"`
PlatformUserID string `json:"platform_user_id"`
CreatedAt time.Time `json:"created_at"`
}
func bindChannel(ctx context.Context, userID uint64, req BindRequest) (BindingDTO, error) {
channelID, err := strconv.ParseUint(strings.TrimSpace(req.ChannelID), 10, 64)
if err != nil || channelID == 0 {
return BindingDTO{}, errChannelIDRequired
}
code := NormalizeCode(req.Code)
if code == "" {
return BindingDTO{}, errCodeInvalid
}
pairing, err := GetPairingCode(ctx, code)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return BindingDTO{}, errCodeInvalid
}
return BindingDTO{}, err
}
if !pairing.ExpiresAt.After(time.Now()) {
return BindingDTO{}, errCodeInvalid
}
if pairing.ChannelID != channelID {
return BindingDTO{}, errChannelMismatch
}
ch, err := GetMessageChannel(ctx, channelID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return BindingDTO{}, errCodeInvalid
}
return BindingDTO{}, err
}
if !ch.Enabled {
return BindingDTO{}, errChannelDisabled
}
existing, err := GetBindingByChannelPlatform(ctx, channelID, pairing.PlatformUserID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return BindingDTO{}, err
}
if err == nil && existing != nil {
if existing.UserID != userID {
return BindingDTO{}, errPlatformAlreadyBound
}
_ = DeletePairingCode(ctx, pairing.Code)
return toBindingDTO(existing, ch), nil
}
row := &MessageBinding{
UserID: userID,
ChannelID: channelID,
PlatformUserID: pairing.PlatformUserID,
}
if err := CreateMessageBinding(ctx, row); err != nil {
return BindingDTO{}, err
}
if err := DeletePairingCode(ctx, pairing.Code); err != nil {
return BindingDTO{}, err
}
return toBindingDTO(row, ch), nil
}
// PublicChannelDTO is an enabled channel a user can bind to.
type PublicChannelDTO struct {
ID uint64 `json:"id,string"`
Name string `json:"name"`
Type string `json:"type"`
}
func listEnabledPublicChannels(ctx context.Context) ([]PublicChannelDTO, error) {
rows, err := ListEnabledMessageChannels(ctx)
if err != nil {
return nil, err
}
out := make([]PublicChannelDTO, 0, len(rows))
for _, row := range rows {
out = append(out, PublicChannelDTO{ID: row.ID, Name: row.Name, Type: row.Type})
}
return out, nil
}
func listUserBindings(ctx context.Context, userID uint64) ([]BindingDTO, error) {
rows, err := ListBindingsByUser(ctx, userID)
if err != nil {
return nil, err
}
out := make([]BindingDTO, 0, len(rows))
for i := range rows {
ch, err := GetMessageChannel(ctx, rows[i].ChannelID)
if err != nil {
out = append(out, toBindingDTO(&rows[i], nil))
continue
}
out = append(out, toBindingDTO(&rows[i], ch))
}
return out, nil
}
func unbindChannel(ctx context.Context, userID, bindingID uint64) error {
row, err := GetMessageBinding(ctx, bindingID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errBindingNotFound
}
return err
}
if row.UserID != userID {
return errBindingForbidden
}
return DeleteMessageBinding(ctx, bindingID)
}
func toBindingDTO(row *MessageBinding, ch *MessageChannel) BindingDTO {
dto := BindingDTO{
ID: row.ID,
UserID: row.UserID,
ChannelID: row.ChannelID,
PlatformUserID: row.PlatformUserID,
CreatedAt: row.CreatedAt,
}
if ch != nil {
dto.ChannelName = ch.Name
dto.ChannelType = ch.Type
}
return dto
}
@@ -0,0 +1,46 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
// 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"`
}
@@ -1,16 +1,20 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
// Package model defines the domain entities, DTOs, and schemas for message_gateway.
package model
import (
"Wavelet/plugins/domain/message_gateway/errs"
"errors"
"strings"
"time"
)
// Message channel and push channel constants.
// Channel type and scope constants.
const (
ChannelTypeTelegram = "telegram"
ChannelTypeQQ = "qq"
MessageChannelTypeTelegram = "telegram"
MessageChannelTypeQQ = "qq"
MessageOwnerScopeSystem = "system"
@@ -20,6 +24,57 @@ const (
TypeTelegram = "telegram"
)
// Capability describes what an adapter can send and receive.
type Capability struct {
Text bool
Image bool
File bool
Reply bool
Group bool
}
// ChannelConfig is the decrypted runtime config passed to a factory.
type ChannelConfig struct {
ID uint64
Type string
Name string
Credentials map[string]string
Extra map[string]string
}
// Recipient is the outbound destination on a platform.
type Recipient struct {
ChatID string
PlatformUserID string
}
// Attachment is a downloaded inbound file sitting on local disk.
type Attachment struct {
Path string
FileName string
MIME string
Error string
}
// InboundMessage is a normalized private-chat message.
type InboundMessage struct {
ChannelID uint64
ChannelType string
PlatformUserID string
ChatID string
MessageID string
Text string
Attachments []Attachment
BindingUserID *uint64
}
// OutboundMessage is a reply or probe send.
type OutboundMessage struct {
Text string
ReplyToID string
Attachments []Attachment
}
// MessageChannel is an admin-configured messaging adapter.
type MessageChannel struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
@@ -92,11 +147,11 @@ func (PushChannel) TableName() string {
func (c *PushChannel) Validate() error {
c.Name = strings.TrimSpace(c.Name)
if c.Name == "" {
return errors.New("channel name is required")
return errors.New(errs.ErrChannelNameRequired)
}
c.Type = strings.TrimSpace(c.Type)
if c.Type == "" {
return errors.New("channel type is required")
return errors.New(errs.ErrChannelTypeRequired)
}
return nil
}
@@ -124,11 +179,11 @@ func (PushEvent) TableName() string {
func (e *PushEvent) Validate() error {
e.EventKey = strings.TrimSpace(e.EventKey)
if e.EventKey == "" {
return errors.New("event_key is required")
return errors.New(errs.ErrEventKeyRequired)
}
e.Name = strings.TrimSpace(e.Name)
if e.Name == "" {
return errors.New("name is required")
return errors.New(errs.ErrNameRequired)
}
return nil
}
@@ -153,13 +208,35 @@ func (PushHistory) TableName() string {
return "w_push_histories"
}
// PushHistoryListFilter filters push history pagination queries.
type PushHistoryListFilter struct {
EventKey string
Channel string
Status string
StartTime *time.Time
EndTime *time.Time
Page int
PageSize int
// BindRequest is the user bind body.
type BindRequest struct {
ChannelID string `json:"channel_id"`
Code string `json:"code"`
}
// BindingDTO is a user-facing binding row.
type BindingDTO struct {
ID uint64 `json:"id,string"`
UserID uint64 `json:"user_id,string"`
ChannelID uint64 `json:"channel_id,string"`
ChannelName string `json:"channel_name"`
ChannelType string `json:"channel_type"`
PlatformUserID string `json:"platform_user_id"`
CreatedAt time.Time `json:"created_at"`
}
// PublicChannelDTO is an enabled channel a user can bind to.
type PublicChannelDTO struct {
ID uint64 `json:"id,string"`
Name string `json:"name"`
Type string `json:"type"`
}
// PushNotificationEvent defines the payload for eventbus notification trigger.
type PushNotificationEvent struct {
UserID uint64 `json:"user_id"`
Channel string `json:"channel"`
Title string `json:"title"`
Content string `json:"content"`
Metadata map[string]any `json:"metadata,omitempty"`
}
@@ -1,39 +1,49 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
package model
import (
"Wavelet/pkg/response"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"sync"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"time"
pkgpush "Wavelet/plugins/domain/message_gateway/push"
)
// Push channel and payload constants.
const (
// KeyURL represents the URL field key
ChannelCustom = "custom"
ChannelEmail = "email"
ChannelLark = "lark"
ChannelTelegram = "telegram"
DefaultLevelInfo = "INFO"
KeyTitle = "title"
KeyContent = "content"
KeyLevel = "level"
// KeyURL represents the URL field key.
KeyURL = "url"
// KeyToken represents the Token field key
// KeyToken represents the Token field key.
KeyToken = "token"
// KeyOther represents the Other field key
// KeyOther represents the Other field key.
KeyOther = "other"
// TypeText represents standard text input type
// TypeText represents standard text input type.
TypeText = "text"
// TypePassword represents password input type
// TypePassword represents password input type.
TypePassword = "password"
// TypeTextarea represents textarea input type
// TypeTextarea represents textarea input type.
TypeTextarea = "textarea"
)
// SMTPConfig mirrors the system SMTP settings consumed by the push service.
type SMTPConfig struct {
Host string
Port string
Username string
Password string
}
// PushField represents a form field configuration for a channel.
type PushField struct {
Key string `json:"key"`
@@ -52,6 +62,120 @@ type PushDefinition struct {
Fields []PushField `json:"fields"`
}
// CreatePushChannelRequest is the create channel request payload.
type CreatePushChannelRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Type string `json:"type" binding:"required"`
Token string `json:"token"`
URL string `json:"url"`
Other string `json:"other"`
Enabled bool `json:"enabled"`
}
// UpdatePushChannelRequest is the update channel request payload.
type UpdatePushChannelRequest struct {
Description string `json:"description"`
Type string `json:"type" binding:"required"`
Token string `json:"token"`
URL string `json:"url"`
Other string `json:"other"`
Enabled bool `json:"enabled"`
}
// TestPushChannelRequest is the test channel request payload.
type TestPushChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Token string `json:"token"`
URL string `json:"url"`
Other string `json:"other"`
Target string `json:"target"`
}
// CustomPushRequest contains custom webhook parameters.
type CustomPushRequest struct {
Title string `json:"title" form:"title"`
Description string `json:"description" form:"description"`
Content string `json:"content" form:"content"`
URL string `json:"url" form:"url"`
To string `json:"to" form:"to"`
Token string `json:"token" form:"token"`
}
// CreatePushEventRequest is the request body for creating a push event.
type CreatePushEventRequest struct {
EventKey string `json:"event_key"`
TaskType string `json:"task_type"`
Channels []string `json:"channels"`
Targets []string `json:"targets"`
Template string `json:"template"`
Enabled bool `json:"enabled"`
}
// UpdatePushEventRequest is the request body for updating a push event.
type UpdatePushEventRequest struct {
Channels []string `json:"channels"`
Targets []string `json:"targets"`
Template string `json:"template" binding:"required"`
Enabled bool `json:"enabled"`
}
// TestPushRequest is the request body for testing push config.
type TestPushRequest struct {
Config pkgpush.Config `json:"config" binding:"required"`
Target string `json:"target"`
}
// NotificationMessage represents the structured notification message payload.
type NotificationMessage struct {
Title string `json:"title"`
Content string `json:"content"`
Level string `json:"level"`
Ext map[string]any `json:"ext,omitempty"`
}
// Flatten converts the structured NotificationMessage back to a flat map (original json structure).
func (m NotificationMessage) Flatten() map[string]any {
res := map[string]any{
KeyTitle: m.Title,
KeyContent: m.Content,
KeyLevel: m.Level,
}
for k, v := range m.Ext {
res[k] = v
}
return res
}
// EventMetadata represents the metadata of a push notification event.
type EventMetadata struct {
Key string `json:"key"`
Name string `json:"name"`
DefaultTemplate NotificationMessage `json:"default_template"`
Description string `json:"description"`
}
// SendPayload is the async push dispatch载荷 consumed by the notification worker.
type SendPayload struct {
EventKey string `json:"event_key"`
Config pkgpush.Config `json:"config"`
Target string `json:"target"`
Body NotificationMessage `json:"body"`
Template string `json:"template"`
}
// PushHistoryListFilter filters push history pagination queries.
type PushHistoryListFilter struct {
EventKey string
Channel string
Status string
StartTime *time.Time
EndTime *time.Time
Page int
PageSize int
}
var (
pushDefMu sync.RWMutex
pushDefinitions = make(map[string]PushDefinition)
@@ -69,7 +193,7 @@ func ListPushDefinitions() []PushDefinition {
pushDefMu.RLock()
defer pushDefMu.RUnlock()
order := []string{channelCustom, channelLark, channelTelegram, channelEmail}
order := []string{ChannelCustom, ChannelLark, ChannelTelegram, ChannelEmail}
res := make([]PushDefinition, 0, len(pushDefinitions))
for _, t := range order {
if d, ok := pushDefinitions[t]; ok {
@@ -93,7 +217,7 @@ func ListPushDefinitions() []PushDefinition {
func init() {
RegisterPushChannelDefinition(PushDefinition{
Type: channelCustom,
Type: ChannelCustom,
Name: "自定义消息通道",
Description: "使用自定义 HTTP POST 请求向外部 Webhook 发送数据。",
Fields: []PushField{
@@ -117,7 +241,7 @@ func init() {
})
RegisterPushChannelDefinition(PushDefinition{
Type: channelLark,
Type: ChannelLark,
Name: "飞书群机器人",
Description: "配置飞书群自定义机器人的 Webhook 接口投递。",
Fields: []PushField{
@@ -149,7 +273,7 @@ func init() {
})
RegisterPushChannelDefinition(PushDefinition{
Type: channelTelegram,
Type: ChannelTelegram,
Name: "Telegram 机器人",
Description: "配置 Telegram 机器人推送消息。",
Fields: []PushField{
@@ -181,200 +305,9 @@ func init() {
})
RegisterPushChannelDefinition(PushDefinition{
Type: channelEmail,
Type: ChannelEmail,
Name: "邮件推送通道",
Description: "邮件推送通道直接使用系统全局 SMTP 设置进行发送,无需在此填写服务器配置。",
Fields: []PushField{},
})
}
// ListPushChannelDefinitions returns channel definitions.
func ListPushChannelDefinitions(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(ListPushDefinitions()))
}
// ListPushChannels lists configured push channels.
func ListPushChannels(c *gin.Context) {
channels, err := listPushChannels(c.Request.Context())
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(channels))
}
// CreatePushChannelRequest is the create channel request payload.
type CreatePushChannelRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Type string `json:"type" binding:"required"`
Token string `json:"token"`
URL string `json:"url"`
Other string `json:"other"`
Enabled bool `json:"enabled"`
}
func parsePushChannelID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortBadRequest(c, "invalid channel id")
return 0, false
}
return id, true
}
func handlePushChannelNotFoundError(c *gin.Context, err error, fallback func(c *gin.Context, msg string)) {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.AbortNotFound(c, "channel not found")
return
}
fallback(c, err.Error())
}
// CreatePushChannel creates a push channel.
func CreatePushChannel(c *gin.Context) {
handleJSONRequest(c, createPushChannel)
}
// UpdatePushChannelRequest is the update channel request payload.
type UpdatePushChannelRequest struct {
Description string `json:"description"`
Type string `json:"type" binding:"required"`
Token string `json:"token"`
URL string `json:"url"`
Other string `json:"other"`
Enabled bool `json:"enabled"`
}
// UpdatePushChannel updates a push channel.
func UpdatePushChannel(c *gin.Context) {
handleEntityUpdate(c, parsePushChannelID, updatePushChannel, func(c *gin.Context, err error) {
handlePushChannelNotFoundError(c, err, response.AbortInternal)
})
}
// DeletePushChannel deletes a push channel.
func DeletePushChannel(c *gin.Context) {
id, ok := parsePushChannelID(c)
if !ok {
return
}
if err := deletePushChannel(c.Request.Context(), id); err != nil {
handlePushChannelNotFoundError(c, err, response.AbortInternal)
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// TestPushChannelRequest is the test channel request payload.
type TestPushChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Token string `json:"token"`
URL string `json:"url"`
Other string `json:"other"`
Target string `json:"target"`
}
// TestPushChannel tests connectivity of a push channel.
func TestPushChannel(c *gin.Context) {
var req TestPushChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
ctx := c.Request.Context()
url, token, other, channelType, err := loadChannelForTest(ctx, req)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if channelType == channelEmail {
url, token, other = resolveSMTPConfig(ctx, url, token, other)
}
tempChannel := PushChannel{
Name: "test_temp",
URL: url,
Token: token,
Other: other,
Type: channelType,
Enabled: true,
}
if err := tempChannel.Validate(); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
url = tempChannel.URL
var config pkgpush.Config
var renderedJSON string
switch channelType {
case channelLark:
config = pkgpush.Config{Channel: channelLark, URL: url, Secret: token}
renderedJSON = other
case channelEmail:
config = pkgpush.Config{Channel: channelEmail, URL: url, Key: token, Secret: other}
case channelTelegram:
config = pkgpush.Config{Channel: channelTelegram, URL: url, Secret: token, Key: other}
default:
config = pkgpush.Config{Channel: channelCustom, URL: url}
customPushReq := CustomPushRequest{
Title: "通道测试通知",
Content: "这是一条来自系统的消息通道连通性测试消息。",
Description: "系统通道测试",
URL: "https://example.com",
To: req.Target,
}
renderedJSON = renderCustomPayload(other, customPushReq)
}
payload := SendPayload{
EventKey: "test_channel",
Config: config,
Target: req.Target,
Body: NotificationMessage{
Title: "通道测试通知",
Content: "这是一条来自系统的消息通道连通性测试消息。",
Level: defaultLevelInfo,
},
Template: renderedJSON,
}
if err := enqueuePushTask(ctx, payload); err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// CustomPushRequest contains custom webhook parameters.
type CustomPushRequest struct {
Title string `json:"title" form:"title"`
Description string `json:"description" form:"description"`
Content string `json:"content" form:"content"`
URL string `json:"url" form:"url"`
To string `json:"to" form:"to"`
Token string `json:"token" form:"token"`
}
func escapeJSONString(s string) string {
b, _ := json.Marshal(s)
const minJSONLen = 2
if len(b) >= minJSONLen {
return string(b[1 : len(b)-1])
}
return s
}
func renderCustomPayload(template string, req CustomPushRequest) string {
result := template
result = strings.ReplaceAll(result, "$title", escapeJSONString(req.Title))
result = strings.ReplaceAll(result, "$description", escapeJSONString(req.Description))
result = strings.ReplaceAll(result, "$content", escapeJSONString(req.Content))
result = strings.ReplaceAll(result, "$url", escapeJSONString(req.URL))
result = strings.ReplaceAll(result, "$to", escapeJSONString(req.To))
return result
}
@@ -1,50 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import (
"crypto/rand"
"strings"
"unicode"
)
// CodeAlphabet excludes easily confused runes 0/O/1/I.
const CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
// CodeLength is the raw pairing code size.
const CodeLength = 8
// GenerateCode returns an 8-character pairing code.
func GenerateCode() (string, error) {
buf := make([]byte, CodeLength)
if _, err := rand.Read(buf); err != nil {
return "", err
}
out := make([]byte, CodeLength)
for i, b := range buf {
out[i] = CodeAlphabet[int(b)%len(CodeAlphabet)]
}
return string(out), nil
}
// NormalizeCode strips separators and uppercases.
func NormalizeCode(s string) string {
var b strings.Builder
for _, r := range s {
if r == '-' || unicode.IsSpace(r) {
continue
}
b.WriteRune(unicode.ToUpper(r))
}
return b.String()
}
// FormatCode renders ABCD-EFGH.
func FormatCode(s string) string {
s = NormalizeCode(s)
if len(s) != CodeLength {
return s
}
return s[:4] + "-" + s[4:]
}
+148 -74
View File
@@ -9,6 +9,10 @@ import (
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/message_gateway/handler"
"Wavelet/plugins/domain/message_gateway/model"
"Wavelet/plugins/domain/message_gateway/repository"
"Wavelet/plugins/domain/message_gateway/service"
"context"
"embed"
"reflect"
@@ -68,51 +72,45 @@ func (p *Plugin) Manifest() core.Manifest {
}
}
// PushNotificationEvent defines the payload for eventbus notification trigger.
type PushNotificationEvent struct {
UserID uint64 `json:"user_id"`
Channel string `json:"channel"`
Title string `json:"title"`
Content string `json:"content"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// Apply registers message_gateway migrations, routes, tasks, schedules, events, and settings into the Context.
func (p *Plugin) Apply(ctx *core.Context) error {
// 0. Bind DBService, CacheService, TaskService, UserService
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
setDBService(db)
repository.SetDBService(db)
} else {
core.When[contracts.DBService](ctx, func(db contracts.DBService) {
setDBService(db)
repository.SetDBService(db)
})
}
if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil {
setCacheService(cache)
repository.SetCacheService(cache)
service.SetCacheService(cache)
} else {
core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) {
setCacheService(cache)
repository.SetCacheService(cache)
service.SetCacheService(cache)
})
}
if taskSvc, err := core.Inject[contracts.TaskService](ctx); err == nil && taskSvc != nil {
setTaskService(taskSvc)
service.SetTaskService(taskSvc)
} else {
core.When[contracts.TaskService](ctx, func(taskSvc contracts.TaskService) {
setTaskService(taskSvc)
service.SetTaskService(taskSvc)
})
}
if uSvc, err := core.Inject[contracts.UserService](ctx); err == nil && uSvc != nil {
setUserService(uSvc)
service.SetUserService(uSvc)
} else {
core.When[contracts.UserService](ctx, func(uSvc contracts.UserService) {
setUserService(uSvc)
service.SetUserService(uSvc)
})
}
ctx.OnDispose(func() error {
setDBService(nil)
setCacheService(nil)
setTaskService(nil)
setUserService(nil)
repository.SetDBService(nil)
repository.SetCacheService(nil)
service.SetCacheService(nil)
service.SetTaskService(nil)
service.SetUserService(nil)
return nil
})
@@ -132,61 +130,23 @@ func (p *Plugin) Apply(ctx *core.Context) error {
ctx.Migrations().Register("message_gateway", mgMigrations)
// 2. Register User HTTP Routes
mgGroup := ctx.Router().Group("/api/v1/message-gateway", loginMW)
{
mgGroup.GET("/channels", ListChannels)
mgGroup.GET("/bindings", ListBindings)
mgGroup.POST("/bindings", BindBinding)
mgGroup.DELETE("/bindings/:id", UnbindBinding)
}
handler.RegisterUserRoutes(ctx.Router().Group("/api/v1"), loginMW)
// 3. Register Admin Message Gateway HTTP Routes
adminMgGroup := ctx.Router().Group("/api/v1/admin/message-gateway", loginMW, adminMW)
{
adminMgGroup.GET("/channels/definitions", ListAdminChannelDefinitions)
adminMgGroup.GET("/channels", ListAdminChannels)
adminMgGroup.POST("/channels", CreateAdminChannel)
adminMgGroup.PATCH("/channels/:id", UpdateAdminChannel)
adminMgGroup.DELETE("/channels/:id", DeleteAdminChannel)
adminMgGroup.POST("/channels/:id/test", TestAdminChannel)
}
handler.RegisterAdminRoutes(ctx.Router().Group("/api/v1/admin"), loginMW, adminMW)
// 4. Register Admin Push HTTP Routes
adminPushGroup := ctx.Router().Group("/api/v1/admin/push", loginMW, adminMW)
{
events := adminPushGroup.Group("/events")
{
events.GET("", ListPushEvents)
events.GET("/builtin", ListBuiltInPushEvents)
events.POST("", CreatePushEvent)
events.PUT("/:id", UpdatePushEvent)
events.DELETE("/:id", DeletePushEvent)
events.POST("/:id/toggle", TogglePushEvent)
}
adminPushGroup.GET("/histories", ListPushHistories)
adminPushGroup.POST("/test", TestPush)
channels := adminPushGroup.Group("/channels")
{
channels.GET("/definitions", ListPushChannelDefinitions)
channels.GET("", ListPushChannels)
channels.POST("", CreatePushChannel)
channels.PUT("/:id", UpdatePushChannel)
channels.DELETE("/:id", DeletePushChannel)
channels.POST("/test", TestPushChannel)
}
}
handler.RegisterAdminPushRoutes(ctx.Router().Group("/api/v1/admin"), loginMW, adminMW)
const defaultTaskRetry = 3
pushHandler := &PushHandler{}
pushHandler := &service.PushHandler{}
// 5. Register background tasks
ctx.Task().Register("message_gateway:push_notification", func(c context.Context, payload []byte) error {
return pushHandler.Execute(c, payload)
}, extpoints.WithTaskRetry(defaultTaskRetry))
ctx.Task().Register(SendNotificationTask, func(c context.Context, payload []byte) error {
ctx.Task().Register(service.SendNotificationTask, func(c context.Context, payload []byte) error {
return pushHandler.Execute(c, payload)
}, extpoints.WithTaskRetry(defaultTaskRetry))
@@ -198,19 +158,19 @@ func (p *Plugin) Apply(ctx *core.Context) error {
ctx.Schedule().RegisterCron("*/10 * * * *", "message_gateway:cleanup_pairing_codes", map[string]any{"action": "cleanup"})
// 7. Register EventBus listeners for decoupled push triggers
ctx.Events().On("notification:push", func(c context.Context, e PushNotificationEvent) error {
meta := EventMetadata{
ctx.Events().On("notification:push", func(c context.Context, e model.PushNotificationEvent) error {
meta := model.EventMetadata{
Key: "eventbus:" + e.Channel,
Name: e.Title,
DefaultTemplate: NotificationMessage{
DefaultTemplate: model.NotificationMessage{
Title: e.Title,
Content: e.Content,
Level: defaultLevelInfo,
Level: model.DefaultLevelInfo,
Ext: e.Metadata,
},
Description: "EventBus triggered notification",
}
DefaultTrigger.Trigger(c, meta, map[string]any{
service.DefaultTrigger.Trigger(c, meta, map[string]any{
"user.id": e.UserID,
"title": e.Title,
"content": e.Content,
@@ -220,14 +180,14 @@ func (p *Plugin) Apply(ctx *core.Context) error {
// 8. Register task completed event listener
ctx.Events().On(contracts.EventTopicTaskCompleted, func(c context.Context, e contracts.TaskCompletedEvent) error {
handleTaskCompleted(c, e)
service.HandleTaskCompleted(c, e)
return nil
})
// 9. Register built-in domain events
RegisterCustomEvents()
service.RegisterCustomEvents()
// 9. Register Settings Schemas
// 10. Register Settings Schemas
ctx.Settings().Register(extpoints.SettingSchema{
Key: "message_gateway.pairing_code_expiry_minutes",
Default: 15,
@@ -243,12 +203,12 @@ func (p *Plugin) Apply(ctx *core.Context) error {
Category: "messaging",
})
// 10. Optional runner start & lifecycle
// 11. Optional runner start & lifecycle
if p.autoStartRunner {
runnerCtx, cancel := context.WithCancel(ctx.GoContext())
p.cancelRunner = cancel
util.Go(func() {
_ = Start(runnerCtx)
_ = service.Start(runnerCtx)
})
}
@@ -261,3 +221,117 @@ func (p *Plugin) Apply(ctx *core.Context) error {
return nil
}
// Re-exported constants.
const (
CodeAlphabet = service.CodeAlphabet
CodeLength = service.CodeLength
)
// MessageChannel is an alias for model.MessageChannel.
type MessageChannel = model.MessageChannel
// MessageBinding is an alias for model.MessageBinding.
type MessageBinding = model.MessageBinding
// MessagePairingCode is an alias for model.MessagePairingCode.
type MessagePairingCode = model.MessagePairingCode
// PushChannel is an alias for model.PushChannel.
type PushChannel = model.PushChannel
// PushEvent is an alias for model.PushEvent.
type PushEvent = model.PushEvent
// PushHistory is an alias for model.PushHistory.
type PushHistory = model.PushHistory
// PushNotificationEvent is an alias for model.PushNotificationEvent.
type PushNotificationEvent = model.PushNotificationEvent
// ChannelConfig is an alias for model.ChannelConfig.
type ChannelConfig = model.ChannelConfig
// Capability is an alias for model.Capability.
type Capability = model.Capability
// Recipient is an alias for model.Recipient.
type Recipient = model.Recipient
// Attachment is an alias for model.Attachment.
type Attachment = model.Attachment
// InboundMessage is an alias for model.InboundMessage.
type InboundMessage = model.InboundMessage
// OutboundMessage is an alias for model.OutboundMessage.
type OutboundMessage = model.OutboundMessage
// BindingDTO is an alias for model.BindingDTO.
type BindingDTO = model.BindingDTO
// PublicChannelDTO is an alias for model.PublicChannelDTO.
type PublicChannelDTO = model.PublicChannelDTO
// Definition is an alias for model.Definition.
type Definition = model.Definition
// ChannelDTO is an alias for model.ChannelDTO.
type ChannelDTO = model.ChannelDTO
// CreateChannelRequest is an alias for model.CreateChannelRequest.
type CreateChannelRequest = model.CreateChannelRequest
// UpdateChannelRequest is an alias for model.UpdateChannelRequest.
type UpdateChannelRequest = model.UpdateChannelRequest
// PushDefinition is an alias for model.PushDefinition.
type PushDefinition = model.PushDefinition
// PushField is an alias for model.PushField.
type PushField = model.PushField
// NotificationMessage is an alias for model.NotificationMessage.
type NotificationMessage = model.NotificationMessage
// EventMetadata is an alias for model.EventMetadata.
type EventMetadata = model.EventMetadata
// SendPayload is an alias for model.SendPayload.
type SendPayload = model.SendPayload
// Handler is an alias for service.Handler.
type Handler = service.Handler
// Factory is an alias for service.Factory.
type Factory = service.Factory
// Channel is an alias for service.Channel.
type Channel = service.Channel
// Runner is an alias for service.Runner.
type Runner = service.Runner
// EventTrigger is an alias for service.EventTrigger.
type EventTrigger = service.EventTrigger
// PushHandler is an alias for service.PushHandler.
type PushHandler = service.PushHandler
// Re-exported variables and functions.
var (
SetDBServiceForTest = repository.SetDBServiceForTest
UpsertPairingCode = repository.UpsertPairingCode
Register = service.Register
Lookup = service.Lookup
GenerateCode = service.GenerateCode
NormalizeCode = service.NormalizeCode
FormatCode = service.FormatCode
Start = service.Start
Stop = service.Stop
GlobalRunner = service.GlobalRunner
DefaultTrigger = service.DefaultTrigger
SyncEvents = service.SyncEvents
AdminLogin = service.AdminLogin
HandleAdminLoggedIn = service.HandleAdminLoggedIn
)
@@ -1,15 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
const (
channelCustom = "custom"
channelEmail = "email"
channelLark = "lark"
channelTelegram = "telegram"
defaultLevelInfo = "INFO"
keyTitle = "title"
keyContent = "content"
keyLevel = "level"
)
@@ -1,273 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import (
"Wavelet/pkg/logger"
"Wavelet/pkg/util"
"context"
"encoding/json"
"errors"
"sync"
pkgpush "Wavelet/plugins/domain/message_gateway/push"
"gorm.io/gorm"
)
// NotificationMessage represents the structured notification message payload.
type NotificationMessage struct {
Title string `json:"title"`
Content string `json:"content"`
Level string `json:"level"`
Ext map[string]any `json:"ext,omitempty"`
}
// Flatten converts the structured NotificationMessage back to a flat map (original json structure).
func (m NotificationMessage) Flatten() map[string]any {
res := map[string]any{
keyTitle: m.Title,
keyContent: m.Content,
keyLevel: m.Level,
}
for k, v := range m.Ext {
res[k] = v
}
return res
}
// EventMetadata represents the metadata of a push notification event.
type EventMetadata struct {
Key string `json:"key"`
Name string `json:"name"`
DefaultTemplate NotificationMessage `json:"default_template"`
Description string `json:"description"`
}
// SendPayload 异步投递推送载荷 (供 task/Worker 使用)
type SendPayload struct {
EventKey string `json:"event_key"`
Config pkgpush.Config `json:"config"`
Target string `json:"target"`
Body NotificationMessage `json:"body"`
Template string `json:"template"`
}
var (
builtInEventsMu sync.RWMutex
// BuiltInEvents lists all built-in events defined in custom_events.
BuiltInEvents []EventMetadata
)
// RegisterBuiltInEvent registers a built-in event definition.
func RegisterBuiltInEvent(meta EventMetadata) {
builtInEventsMu.Lock()
defer builtInEventsMu.Unlock()
for i, e := range BuiltInEvents {
if e.Key == meta.Key {
BuiltInEvents[i] = meta
return
}
}
BuiltInEvents = append(BuiltInEvents, meta)
}
// GetBuiltInEvents returns a copy of registered built-in events.
func GetBuiltInEvents() []EventMetadata {
builtInEventsMu.RLock()
defer builtInEventsMu.RUnlock()
out := make([]EventMetadata, len(BuiltInEvents))
copy(out, BuiltInEvents)
return out
}
// EventTrigger represents the unified event trigger class.
type EventTrigger struct{}
// DefaultTrigger is the singleton instance of EventTrigger.
var DefaultTrigger = &EventTrigger{}
// Trigger receives event metadata and processes the event notification dispatch asynchronously.
//
//nolint:contextcheck
func (t *EventTrigger) Trigger(ctx context.Context, meta EventMetadata, body map[string]any) {
asyncCtx := context.WithoutCancel(ctx)
util.Go(func() {
if body == nil {
body = make(map[string]any)
}
if _, hasUser := body["user"]; !hasUser || body["user"] == nil {
body["user"] = getSystemUser(asyncCtx)
}
eventPtr, err := GetActivePushEventByKey(asyncCtx, meta.Key)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return
}
logger.ErrorF(asyncCtx, "push_event_trigger: failed to get active event %s: %v", meta.Key, err)
return
}
event := *eventPtr
if len(event.Channels) == 0 {
return
}
flatBody := getFlatBody(body)
msg, _ := t.buildMessage(&event, meta, flatBody, body)
t.enqueuePushTasks(asyncCtx, meta, &event, msg, flatBody)
})
}
func (t *EventTrigger) buildMessage(event *PushEvent, meta EventMetadata, flatBody, body map[string]any) (NotificationMessage, string) {
var msg NotificationMessage
renderedTemplate := ""
templateSource := event.Template
if templateSource != "" {
var err error
msg, renderedTemplate, err = t.parseCustomTemplate(event, templateSource, flatBody)
if err != nil {
msg.Title = event.Name
msg.Content = renderedTemplate
msg.Level = defaultLevelInfo
}
} else {
msg = t.parseDefaultTemplate(meta, flatBody)
}
if msg.Ext == nil {
msg.Ext = make(map[string]any)
}
for k, v := range body {
if k == keyTitle || k == keyContent || k == keyLevel {
continue
}
if _, exists := msg.Ext[k]; !exists {
msg.Ext[k] = v
}
}
return msg, renderedTemplate
}
func (t *EventTrigger) parseCustomTemplate(event *PushEvent, templateSource string, flatBody map[string]any) (NotificationMessage, string, error) {
var msg NotificationMessage
renderedTemplate := pkgpush.ParseTemplate(templateSource, flatBody)
var tMap map[string]any
if err := json.Unmarshal([]byte(renderedTemplate), &tMap); err != nil {
return msg, renderedTemplate, err
}
if title, ok := tMap[keyTitle].(string); ok && title != "" {
msg.Title = title
} else {
msg.Title = event.Name
}
delete(tMap, keyTitle)
if content, ok := tMap[keyContent].(string); ok && content != "" {
msg.Content = content
} else {
msg.Content = renderedTemplate
}
delete(tMap, keyContent)
if level, ok := tMap[keyLevel].(string); ok && level != "" {
msg.Level = level
} else {
msg.Level = defaultLevelInfo
}
delete(tMap, keyLevel)
msg.Ext = tMap
return msg, renderedTemplate, nil
}
func (t *EventTrigger) parseDefaultTemplate(meta EventMetadata, flatBody map[string]any) NotificationMessage {
var msg NotificationMessage
msg.Title = pkgpush.ParseTemplate(meta.DefaultTemplate.Title, flatBody)
msg.Content = pkgpush.ParseTemplate(meta.DefaultTemplate.Content, flatBody)
msg.Level = pkgpush.ParseTemplate(meta.DefaultTemplate.Level, flatBody)
if meta.DefaultTemplate.Ext != nil {
msg.Ext = make(map[string]any)
for k, v := range meta.DefaultTemplate.Ext {
if strVal, ok := v.(string); ok {
msg.Ext[k] = pkgpush.ParseTemplate(strVal, flatBody)
} else {
msg.Ext[k] = v
}
}
}
return msg
}
func (t *EventTrigger) enqueuePushTasks(ctx context.Context, meta EventMetadata, event *PushEvent, msg NotificationMessage, flatBody map[string]any) {
for _, channelName := range event.Channels {
customChannel, err := GetActivePushChannelByName(ctx, channelName)
if err == nil {
t.enqueueCustomPushChannelTasks(ctx, meta, event, customChannel, msg, flatBody)
continue
}
logger.WarnF(ctx, "push_event_trigger: channel %q not found in DB or disabled: %v", channelName, err)
}
}
func (t *EventTrigger) enqueueCustomPushChannelTasks(ctx context.Context, meta EventMetadata, event *PushEvent, channel *PushChannel, msg NotificationMessage, flatBody map[string]any) {
if len(event.Targets) == 0 {
t.enqueueSingleCustomPushChannelTask(ctx, meta, channel, "", msg)
return
}
for _, target := range event.Targets {
resolvedTarget := resolveTarget(ctx, target, flatBody, channel.Name)
t.enqueueSingleCustomPushChannelTask(ctx, meta, channel, resolvedTarget, msg)
}
}
func (t *EventTrigger) enqueueSingleCustomPushChannelTask(ctx context.Context, meta EventMetadata, channel *PushChannel, target string, msg NotificationMessage) {
var config pkgpush.Config
var renderedTemplate string
switch channel.Type {
case channelLark:
config = pkgpush.Config{Channel: channelLark, URL: channel.URL, Secret: channel.Token}
renderedTemplate = channel.Other
case channelEmail:
url, token, other := resolveSMTPConfig(ctx, channel.URL, channel.Token, channel.Other)
config = pkgpush.Config{Channel: channelEmail, URL: url, Key: token, Secret: other}
case channelTelegram:
config = pkgpush.Config{Channel: channelTelegram, URL: channel.URL, Secret: channel.Token, Key: channel.Other}
default:
config = pkgpush.Config{Channel: channelCustom, URL: channel.URL}
customPushReq := CustomPushRequest{
Title: msg.Title,
Content: msg.Content,
Description: meta.Description,
To: target,
}
if urlVal, ok := msg.Ext["url"].(string); ok {
customPushReq.URL = urlVal
}
renderedTemplate = renderCustomPayload(channel.Other, customPushReq)
}
payload := SendPayload{
EventKey: meta.Key,
Config: config,
Target: target,
Body: msg,
Template: renderedTemplate,
}
if err := enqueuePushTask(ctx, payload); err != nil {
logger.ErrorF(ctx, "push_event_trigger: enqueuePushTask failed for %s channel %s -> %s: %v", channel.Type, channel.Name, target, err)
}
}
// SyncEvents automatically registers/updates built-in events in the database.
func SyncEvents(ctx context.Context) error {
return syncBuiltInEvents(ctx)
}
@@ -1,556 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import (
"Wavelet/core/contracts"
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"gorm.io/gorm"
pkgpush "Wavelet/plugins/domain/message_gateway/push"
)
type smtpConfig struct {
Host string
Port string
Username string
Password string
}
func loadSMTPConfig(ctx context.Context) smtpConfig {
var cfg smtpConfig
var host, port, user, pass string
_ = getDB(ctx).Table("w_system_configs").Where("key = ?", "smtp_host").Pluck("value", &host).Error
_ = getDB(ctx).Table("w_system_configs").Where("key = ?", "smtp_port").Pluck("value", &port).Error
_ = getDB(ctx).Table("w_system_configs").Where("key = ?", "smtp_username").Pluck("value", &user).Error
_ = getDB(ctx).Table("w_system_configs").Where("key = ?", "smtp_password").Pluck("value", &pass).Error
cfg.Host = host
cfg.Port = port
cfg.Username = user
cfg.Password = pass
return cfg
}
func syncBuiltInEvents(ctx context.Context) error {
for _, meta := range GetBuiltInEvents() {
_, err := GetPushEventByKeyRecord(ctx, meta.Key)
if errors.Is(err, gorm.ErrRecordNotFound) {
var defaultTemplateStr string
if defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate); err == nil {
defaultTemplateStr = string(defaultTemplateBytes)
}
event := PushEvent{
EventKey: meta.Key,
Name: meta.Name,
Channels: []string{},
Targets: []string{},
Template: defaultTemplateStr,
Enabled: false,
}
if err := CreatePushEventRecord(ctx, &event); err != nil {
return err
}
} else if err != nil {
return err
}
}
return nil
}
func listPushEvents(ctx context.Context) ([]PushEvent, error) {
return ListPushEventsRecord(ctx)
}
func createPushEvent(ctx context.Context, req CreatePushEventRequest) (PushEvent, error) {
eventKey, eventName, defaultTemplateBytes, err := getEventInfo(req)
if err != nil {
return PushEvent{}, err
}
count, err := CountPushEventsByKeyRecord(ctx, eventKey)
if err != nil {
return PushEvent{}, err
}
if count > 0 {
return PushEvent{}, errors.New("this notification event is already configured")
}
templateStr := strings.TrimSpace(req.Template)
if templateStr == "" {
templateStr = string(defaultTemplateBytes)
} else {
var tempMap map[string]any
if err := json.Unmarshal([]byte(templateStr), &tempMap); err != nil {
return PushEvent{}, errors.New("custom template is not a valid JSON format")
}
}
channels := req.Channels
if channels == nil {
channels = []string{}
}
targets := req.Targets
if targets == nil {
targets = []string{}
}
event := PushEvent{
EventKey: eventKey,
Name: eventName,
TaskType: req.TaskType,
Channels: channels,
Targets: targets,
Template: templateStr,
Enabled: req.Enabled,
}
if err := event.Validate(); err != nil {
return PushEvent{}, err
}
if err := CreatePushEventRecord(ctx, &event); err != nil {
return PushEvent{}, err
}
return event, nil
}
func deletePushEvent(ctx context.Context, id uint64) error {
event, err := GetPushEventByIDRecord(ctx, id)
if err != nil {
return err
}
return DeletePushEventRecord(ctx, &event)
}
func updatePushEvent(ctx context.Context, id uint64, req UpdatePushEventRequest) error {
event, err := GetPushEventByIDRecord(ctx, id)
if err != nil {
return err
}
event.Channels = req.Channels
event.Targets = req.Targets
event.Template = req.Template
event.Enabled = req.Enabled
if err := event.Validate(); err != nil {
return err
}
return SavePushEventRecord(ctx, &event)
}
func togglePushEvent(ctx context.Context, id uint64) (bool, error) {
event, err := GetPushEventByIDRecord(ctx, id)
if err != nil {
return false, err
}
enabled := !event.Enabled
if enabled && len(event.Channels) == 0 {
return false, errors.New("cannot enable event without any push channels configured")
}
if err := UpdatePushEventEnabledRecord(ctx, &event, enabled); err != nil {
return false, err
}
return enabled, nil
}
func listPushHistories(ctx context.Context, filter PushHistoryListFilter) (int64, []PushHistory, error) {
return ListPushHistoriesRecord(ctx, filter)
}
func applySMTPFallbackToPushConfig(ctx context.Context, cfg *pkgpush.Config) {
if cfg.Channel != channelEmail || (cfg.URL != "" && cfg.Key != "") {
return
}
smtp := loadSMTPConfig(ctx)
if smtp.Host == "" || smtp.Username == "" {
return
}
port := smtp.Port
if port == "" {
port = "587"
}
cfg.URL = smtp.Host + ":" + port
cfg.Key = smtp.Username
cfg.Secret = smtp.Password
}
func listPushChannels(ctx context.Context) ([]PushChannel, error) {
return ListPushChannelsRecord(ctx)
}
func createPushChannel(ctx context.Context, req CreatePushChannelRequest) (PushChannel, error) {
count, err := CountPushChannelsByNameRecord(ctx, req.Name)
if err != nil {
return PushChannel{}, err
}
if count > 0 {
return PushChannel{}, errors.New("channel name already exists")
}
channel := PushChannel{
Name: req.Name,
Description: req.Description,
Type: req.Type,
Token: req.Token,
URL: req.URL,
Other: req.Other,
Enabled: req.Enabled,
}
if err := channel.Validate(); err != nil {
return PushChannel{}, err
}
if err := CreatePushChannelRecord(ctx, &channel); err != nil {
return PushChannel{}, err
}
return channel, nil
}
func updatePushChannel(ctx context.Context, id uint64, req UpdatePushChannelRequest) (PushChannel, error) {
channel, err := GetPushChannelByIDRecord(ctx, id)
if err != nil {
return PushChannel{}, err
}
channel.Description = req.Description
channel.Type = req.Type
channel.Token = req.Token
channel.URL = req.URL
channel.Other = req.Other
channel.Enabled = req.Enabled
if err := channel.Validate(); err != nil {
return PushChannel{}, err
}
if err := SavePushChannelRecord(ctx, &channel); err != nil {
return PushChannel{}, err
}
return channel, nil
}
func deletePushChannel(ctx context.Context, id uint64) error {
channel, err := GetPushChannelByIDRecord(ctx, id)
if err != nil {
return err
}
return DeletePushChannelRecord(ctx, &channel)
}
func loadChannelForTest(ctx context.Context, req TestPushChannelRequest) (string, string, string, string, error) {
if req.Name != "" {
channel, err := GetPushChannelByNameRecord(ctx, req.Name)
if err != nil {
return "", "", "", "", errors.New("channel not found")
}
return channel.URL, channel.Token, channel.Other, channel.Type, nil
}
return req.URL, req.Token, req.Other, req.Type, nil
}
func listActivePushEventsByTaskType(ctx context.Context, taskType string) ([]PushEvent, error) {
return ListActivePushEventsByTaskTypeRecord(ctx, taskType)
}
func queryUser(ctx context.Context, fromService func(contracts.UserService) (*contracts.UserDTO, error), dbField string, dbVal any) (*contracts.UserDTO, error) {
if userSvc := getUserService(ctx); userSvc != nil {
return fromService(userSvc)
}
if db := getDB(ctx); db != nil {
var user contracts.UserDTO
if err := db.Table("w_users").Where(dbField+" = ?", dbVal).First(&user).Error; err == nil {
return &user, nil
}
}
return nil, errors.New("user not found")
}
func findUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
return queryUser(ctx, func(s contracts.UserService) (*contracts.UserDTO, error) {
return s.GetUserByID(ctx, id)
}, "id", id)
}
func findUserByUsername(ctx context.Context, username string) (*contracts.UserDTO, error) {
return queryUser(ctx, func(s contracts.UserService) (*contracts.UserDTO, error) {
return s.GetUserByUsername(ctx, username)
}, "username", username)
}
func loadUserFromPayload(ctx context.Context, data map[string]any) any {
if u, exists := data["user"]; exists && u != nil {
return u
}
if userID, ok := extractUserID(data); ok && userID > 0 {
if user, err := findUserByID(ctx, userID); err == nil && user != nil {
return user
}
}
if username := extractUsername(data); username != "" {
if user, err := findUserByUsername(ctx, username); err == nil && user != nil {
return user
}
}
return nil
}
func recordPushHistory(ctx context.Context, req SendPayload, status, errMsg string) error {
title := req.Body.Title
content := req.Body.Content
level := req.Body.Level
if title == "" {
title = "系统通知"
}
if level == "" {
level = defaultLevelInfo
}
target := req.Target
if target == "" {
if req.Config.URL != "" {
target = req.Config.URL
const maxTargetLen = 50
const truncatedLen = 47
if len(target) > maxTargetLen {
target = target[:truncatedLen] + "..."
}
} else {
target = "default"
}
}
history := PushHistory{
EventKey: req.EventKey,
Channel: req.Config.Channel,
Target: target,
Title: title,
Content: content,
Level: level,
Status: status,
ErrorMsg: errMsg,
}
return CreatePushHistoryRecord(ctx, &history)
}
func resolveTarget(ctx context.Context, target string, flatBody map[string]any, channel string) string {
target = strings.TrimSpace(target)
if target == "" {
return ""
}
resolved := resolveDynamicKeyword(target, flatBody)
if strings.Contains(resolved, "@") {
return resolved
}
if val, matched := resolveSystemTarget(ctx, resolved, channel); matched {
return val
}
user, found := resolveTargetUser(ctx, resolved, channel)
if !found {
return resolved
}
if channel == channelEmail && user.Email != "" {
return user.Email
}
if channel != channelEmail && user.Username != "" {
return user.Username
}
return resolved
}
func resolveDynamicKeyword(target string, flatBody map[string]any) string {
switch target {
case "user.id", "id":
if val, ok := flatBody["user.id"]; ok {
return fmt.Sprintf("%v", val)
}
if val, ok := flatBody["id"]; ok {
return fmt.Sprintf("%v", val)
}
case "user.username", "username":
if val, ok := flatBody["user.username"]; ok {
return fmt.Sprintf("%v", val)
}
if val, ok := flatBody["username"]; ok {
return fmt.Sprintf("%v", val)
}
case "user.email", channelEmail:
if val, ok := flatBody["user.email"]; ok {
return fmt.Sprintf("%v", val)
}
if val, ok := flatBody["email"]; ok {
return fmt.Sprintf("%v", val)
}
}
return target
}
func resolveTargetUser(ctx context.Context, resolved, _ string) (contracts.UserDTO, bool) {
if id, err := strconv.ParseUint(resolved, 10, 64); err == nil {
if u, err := findUserByID(ctx, id); err == nil && u != nil {
return *u, true
}
}
if u, err := findUserByUsername(ctx, resolved); err == nil && u != nil {
return *u, true
}
return contracts.UserDTO{}, false
}
func getFirstAdminUser(ctx context.Context) (*contracts.UserDTO, error) {
if userSvc := getUserService(ctx); userSvc != nil {
return userSvc.GetFirstAdminUser(ctx)
}
if db := getDB(ctx); db != nil {
var adminUser contracts.UserDTO
if err := db.Table("w_users").Where("is_admin = ?", true).Order("id ASC").First(&adminUser).Error; err == nil {
return &adminUser, nil
}
}
return nil, errors.New("no admin user found")
}
func resolveSystemTarget(ctx context.Context, resolved, channel string) (string, bool) {
if resolved != "系统" && resolved != "system" && resolved != "0" {
return "", false
}
adminUser, err := getFirstAdminUser(ctx)
if err != nil || adminUser == nil {
return resolved, true
}
if channel == channelEmail && adminUser.Email != "" {
return adminUser.Email, true
}
if channel != channelEmail && adminUser.Username != "" {
return adminUser.Username, true
}
return resolved, true
}
func resolveSMTPConfig(ctx context.Context, url, token, other string) (string, string, string) {
if url != "" && token != "" {
return url, token, other
}
smtp := loadSMTPConfig(ctx)
if smtp.Host == "" || smtp.Username == "" {
return url, token, other
}
port := smtp.Port
if port == "" {
port = "587"
}
if url == "" {
url = smtp.Host + ":" + port
}
if token == "" {
token = smtp.Username
}
if other == "" {
other = smtp.Password
}
return url, token, other
}
func getSystemUser(ctx context.Context) *contracts.UserDTO {
if adminUser, err := getFirstAdminUser(ctx); err == nil && adminUser != nil {
return adminUser
}
return &contracts.UserDTO{
Username: "system",
Nickname: "系统管理员",
}
}
func findBuiltInEvent(key string) (EventMetadata, bool) {
for _, meta := range GetBuiltInEvents() {
if meta.Key == key {
return meta, true
}
}
return EventMetadata{}, false
}
func getEventInfo(req CreatePushEventRequest) (string, string, []byte, error) {
if req.TaskType != "" {
taskName := req.TaskType
if taskSvc := getTaskService(); taskSvc != nil {
if meta, ok := taskSvc.GetTaskMeta(req.TaskType); ok {
taskName = meta.DisplayName
}
}
eventKey := "task_completed:" + req.TaskType
eventName := "任务完成: " + taskName
defaultTemplate := NotificationMessage{
Title: "任务完成: " + taskName,
Content: "异步任务 {{task_name}} (ID: {{task_id}}) 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。",
Level: defaultLevelInfo,
}
defaultTemplateBytes, err := json.Marshal(defaultTemplate)
if err != nil {
return "", "", nil, err
}
return eventKey, eventName, defaultTemplateBytes, nil
}
if req.EventKey == "" {
return "", "", nil, errors.New("either event_key or task_type must be provided")
}
meta, found := findBuiltInEvent(req.EventKey)
if !found {
return "", "", nil, errors.New("unsupported built-in event key")
}
defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate)
if err != nil {
return "", "", nil, err
}
return req.EventKey, meta.Name, defaultTemplateBytes, nil
}
func enqueuePushTask(ctx context.Context, payload SendPayload) error {
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
if taskSvc := getTaskService(); taskSvc != nil {
_, err = taskSvc.Dispatch(ctx, "send_notification", payloadBytes, "system")
return err
}
return errors.New("task service not available")
}
func getFlatBody(body map[string]any) map[string]any {
jsonBytes, err := json.Marshal(body)
if err != nil {
return body
}
var jsonMap map[string]any
if err := json.Unmarshal(jsonBytes, &jsonMap); err != nil {
return body
}
flatResult := make(map[string]any)
flattenMap("", jsonMap, flatResult)
return flatResult
}
func flattenMap(prefix string, m, result map[string]any) {
for k, v := range m {
key := k
if prefix != "" {
key = prefix + "." + k
}
if nestedMap, ok := v.(map[string]any); ok {
flattenMap(key, nestedMap, result)
} else {
result[key] = v
}
}
}
@@ -1,109 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"context"
"encoding/json"
"strconv"
"time"
)
func handleTaskCompleted(ctx context.Context, e contracts.TaskCompletedEvent) {
events, err := listActivePushEventsByTaskType(ctx, e.TaskType)
if err != nil {
logger.ErrorF(ctx, "push_task_completed_listener: failed to query push events for task type %s: %v", e.TaskType, err)
return
}
if len(events) == 0 {
return
}
body := map[string]any{
"task_id": e.TaskID,
"task_name": e.TaskName,
"task_type": e.TaskType,
"task_status": e.Status,
"task_duration": e.Duration,
"time": time.Now().Format("2006-01-02 15:04:05"),
"task_error": e.ErrorMsg,
"task_result": e.ResultMsg,
}
var payloadMap map[string]any
if e.Payload != "" {
if err := json.Unmarshal([]byte(e.Payload), &payloadMap); err == nil {
body["payload"] = payloadMap
extractUserFromMap(ctx, payloadMap, body)
}
}
if e.Detail != "" {
var detailMap map[string]any
if err := json.Unmarshal([]byte(e.Detail), &detailMap); err == nil {
body["detail"] = detailMap
extractUserFromMap(ctx, detailMap, body)
}
}
for _, event := range events {
meta := EventMetadata{
Key: event.EventKey,
Name: event.Name,
Description: "异步任务执行完毕触发的自动通知",
}
DefaultTrigger.Trigger(ctx, meta, body)
}
}
func extractUserFromMap(ctx context.Context, data, body map[string]any) {
if u, exists := body["user"]; exists && u != nil {
return
}
if user := loadUserFromPayload(ctx, data); user != nil {
body["user"] = user
}
}
func extractUserID(data map[string]any) (uint64, bool) {
for _, k := range []string{"user_id", "userId", "uid"} {
val, ok := data[k]
if !ok || val == nil {
continue
}
switch v := val.(type) {
case float64:
if v >= 0 {
return uint64(v), true
}
case int:
if v >= 0 {
return uint64(v), true
}
case int64:
if v >= 0 {
return uint64(v), true
}
case uint64:
return v, true
case string:
if id, err := strconv.ParseUint(v, 10, 64); err == nil {
return id, true
}
}
}
return 0, false
}
func extractUsername(data map[string]any) string {
for _, k := range []string{"username", "user_name"} {
if val, ok := data[k]; ok && val != nil {
if s, ok := val.(string); ok && s != "" {
return s
}
}
}
return ""
}
@@ -1,107 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/message_gateway/push"
"context"
"encoding/json"
"errors"
"fmt"
)
const (
// SendNotificationTask is the asynq task name for push notification.
SendNotificationTask = "push:send"
// TaskTypeSendNotification is the admin task manager type identifier.
TaskTypeSendNotification = "send_notification"
)
// SendNotificationMeta represents the task metadata.
var SendNotificationMeta = contracts.TaskMetaDTO{
Name: TaskTypeSendNotification,
DisplayName: "推送通知",
Description: "异步执行系统通知的多渠道派发与推送",
MaxRetry: 3,
Queue: "default",
Params: []contracts.TaskParamDTO{
{
Name: "event_key",
Type: "string",
Description: "事件标识 (如 admin_login)",
Required: true,
},
{
Name: "target",
Type: "string",
Description: "目标接收者",
Required: false,
},
},
}
// PushHandler handles asynchronous notification sending.
type PushHandler struct{}
// ValidatePayload validates and normalizes push parameters.
func (h *PushHandler) ValidatePayload(payload []byte) ([]byte, error) {
if len(payload) == 0 {
return nil, errors.New("payload is required")
}
var req SendPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, fmt.Errorf("invalid json format: %w", err)
}
if req.Config.Channel == "" {
return nil, errors.New("channel type is required")
}
return json.Marshal(req)
}
// Execute performs the push send and logs delivery history audit.
func (h *PushHandler) Execute(ctx context.Context, payload []byte) error {
var req SendPayload
if err := json.Unmarshal(payload, &req); err != nil {
logger.ErrorF(ctx, "[Push] 解析推送参数失败: %v", err)
return fmt.Errorf("parse payload failed: %w", err)
}
logger.InfoF(ctx, "[Push] 开始推送通知: 事件 = %s, 渠道 = %s, 接收目标 = %s", req.EventKey, req.Config.Channel, req.Target)
pusher, err := push.GetPusher(req.Config.Channel)
if err != nil {
errWrap := fmt.Errorf("get pusher failed: %w", err)
logger.ErrorF(ctx, "[Push] 推送失败: %v", errWrap)
h.recordHistory(ctx, req, "failed", errWrap.Error())
return errWrap
}
flatBody := req.Body.Flatten()
upstreamResp, err := pusher.Send(ctx, req.Config, req.Target, flatBody, req.Template, nil)
title := req.Body.Title
content := req.Body.Content
if err != nil {
logger.ErrorF(ctx, "[Push] 消息推送失败 (标题: %s): %v, 上游返回: %s", title, err, upstreamResp)
h.recordHistory(ctx, req, "failed", err.Error())
return fmt.Errorf("pusher.Send failed: %w", err)
}
logger.InfoF(ctx, "[Push] 消息推送成功 (标题: %s, 内容摘要: %s), 上游返回: %s", title, content, upstreamResp)
h.recordHistory(ctx, req, "success", "")
return nil
}
func (h *PushHandler) recordHistory(ctx context.Context, req SendPayload, status, errMsg string) {
if dbErr := recordPushHistory(ctx, req, status, errMsg); dbErr != nil {
logger.ErrorF(ctx, "[Push] 写入推送历史审计记录失败: %v", dbErr)
}
}
@@ -1,26 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import "sync"
var (
factoriesMu sync.RWMutex
factories = map[string]Factory{}
)
// Register stores a channel factory under typ.
func Register(typ string, fn Factory) {
factoriesMu.Lock()
defer factoriesMu.Unlock()
factories[typ] = fn
}
// Lookup returns a previously registered factory.
func Lookup(typ string) (Factory, bool) {
factoriesMu.RLock()
defer factoriesMu.RUnlock()
fn, ok := factories[typ]
return fn, ok
}
@@ -1,382 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import (
"Wavelet/pkg/idgen"
"context"
"errors"
"time"
"gorm.io/gorm"
)
const (
activePushChannelCacheTTL = 24 * time.Hour
activePushEventCacheTTL = 24 * time.Hour
)
// CreateMessageChannel inserts a channel row.
func CreateMessageChannel(ctx context.Context, ch *MessageChannel) error {
if ch.ID == 0 {
ch.ID = idgen.NextUint64ID()
}
return getDB(ctx).Create(ch).Error
}
// UpdateMessageChannel saves a channel row.
func UpdateMessageChannel(ctx context.Context, ch *MessageChannel) error {
return getDB(ctx).Save(ch).Error
}
// GetMessageChannel loads a channel by id.
func GetMessageChannel(ctx context.Context, id uint64) (*MessageChannel, error) {
var ch MessageChannel
if err := getDB(ctx).Where("id = ?", id).First(&ch).Error; err != nil {
return nil, err
}
return &ch, nil
}
// ListMessageChannels returns all channels newest first.
func ListMessageChannels(ctx context.Context) ([]MessageChannel, error) {
var rows []MessageChannel
if err := getDB(ctx).Order("id DESC").Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// DeleteMessageChannel removes pairings, bindings, then the channel.
func DeleteMessageChannel(ctx context.Context, id uint64) error {
return getDB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("channel_id = ?", id).Delete(&MessagePairingCode{}).Error; err != nil {
return err
}
if err := tx.Where("channel_id = ?", id).Delete(&MessageBinding{}).Error; err != nil {
return err
}
return tx.Delete(&MessageChannel{}, id).Error
})
}
// CreateMessageBinding inserts a binding.
func CreateMessageBinding(ctx context.Context, b *MessageBinding) error {
if b.ID == 0 {
b.ID = idgen.NextUint64ID()
}
return getDB(ctx).Create(b).Error
}
// GetBindingByChannelPlatform finds a binding for a platform user on a channel.
func GetBindingByChannelPlatform(ctx context.Context, channelID uint64, platformUserID string) (*MessageBinding, error) {
var b MessageBinding
err := getDB(ctx).Where("channel_id = ? AND platform_user_id = ?", channelID, platformUserID).First(&b).Error
if err != nil {
return nil, err
}
return &b, nil
}
// ListBindingsByUser lists bindings for a Wavelet user.
func ListBindingsByUser(ctx context.Context, userID uint64) ([]MessageBinding, error) {
var rows []MessageBinding
if err := getDB(ctx).Where("user_id = ?", userID).Order("id DESC").Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// GetMessageBinding loads a binding by id.
func GetMessageBinding(ctx context.Context, id uint64) (*MessageBinding, error) {
var b MessageBinding
if err := getDB(ctx).Where("id = ?", id).First(&b).Error; err != nil {
return nil, err
}
return &b, nil
}
// DeleteMessageBinding deletes a binding by id.
func DeleteMessageBinding(ctx context.Context, id uint64) error {
return getDB(ctx).Delete(&MessageBinding{}, id).Error
}
// UpsertPairingCode reuses an unexpired code for the same channel+platform user.
func UpsertPairingCode(ctx context.Context, channelID uint64, platformUserID, code string, expiresAt time.Time) (*MessagePairingCode, error) {
var existing MessagePairingCode
err := getDB(ctx).
Where("channel_id = ? AND platform_user_id = ? AND expires_at > ?", channelID, platformUserID, time.Now()).
First(&existing).Error
if err == nil {
return &existing, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
row := &MessagePairingCode{
Code: code,
ChannelID: channelID,
PlatformUserID: platformUserID,
ExpiresAt: expiresAt,
}
if err := getDB(ctx).Create(row).Error; err != nil {
return nil, err
}
return row, nil
}
// GetPairingCode loads a pairing code by normalized code string.
func GetPairingCode(ctx context.Context, code string) (*MessagePairingCode, error) {
var row MessagePairingCode
if err := getDB(ctx).Where("code = ?", code).First(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
// DeletePairingCode removes a pairing code.
func DeletePairingCode(ctx context.Context, code string) error {
return getDB(ctx).Where("code = ?", code).Delete(&MessagePairingCode{}).Error
}
// DeleteExpiredPairingCodes removes expired pairing rows.
func DeleteExpiredPairingCodes(ctx context.Context) error {
return getDB(ctx).Where("expires_at <= ?", time.Now()).Delete(&MessagePairingCode{}).Error
}
// ListEnabledMessageChannels returns enabled channels.
func ListEnabledMessageChannels(ctx context.Context) ([]MessageChannel, error) {
var rows []MessageChannel
if err := getDB(ctx).Where("enabled = ?", true).Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// ListPushChannelsRecord returns all push channels ordered by creation time descending.
func ListPushChannelsRecord(ctx context.Context) ([]PushChannel, error) {
var channels []PushChannel
if err := getDB(ctx).Order("created_at DESC").Find(&channels).Error; err != nil {
return nil, err
}
return channels, nil
}
// GetPushChannelByIDRecord loads a push channel by primary key.
func GetPushChannelByIDRecord(ctx context.Context, id uint64) (PushChannel, error) {
var channel PushChannel
if err := getDB(ctx).Where("id = ?", id).First(&channel).Error; err != nil {
return PushChannel{}, err
}
return channel, nil
}
// GetPushChannelByNameRecord 根据名称获取消息通道。
func GetPushChannelByNameRecord(ctx context.Context, name string) (*PushChannel, error) {
var channel PushChannel
if err := getDB(ctx).Where("name = ?", name).First(&channel).Error; err != nil {
return nil, err
}
return &channel, nil
}
// CountPushChannelsByNameRecord returns how many channels share the given name.
func CountPushChannelsByNameRecord(ctx context.Context, name string) (int64, error) {
var count int64
if err := getDB(ctx).Model(&PushChannel{}).Where("name = ?", name).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// CreatePushChannelRecord persists a new channel and invalidates cache.
func CreatePushChannelRecord(ctx context.Context, channel *PushChannel) error {
if err := getDB(ctx).Create(channel).Error; err != nil {
return err
}
DeleteActivePushChannelCache(ctx, channel.Name)
return nil
}
// SavePushChannelRecord updates a channel and invalidates cache.
func SavePushChannelRecord(ctx context.Context, channel *PushChannel) error {
if err := getDB(ctx).Save(channel).Error; err != nil {
return err
}
DeleteActivePushChannelCache(ctx, channel.Name)
return nil
}
// DeletePushChannelRecord removes a channel and invalidates cache.
func DeletePushChannelRecord(ctx context.Context, channel *PushChannel) error {
if err := getDB(ctx).Delete(channel).Error; err != nil {
return err
}
DeleteActivePushChannelCache(ctx, channel.Name)
return nil
}
func getCachedOrQuery[T any](ctx context.Context, cacheKey string, ttl time.Duration, query func(db *gorm.DB, dest *T) error) (*T, error) {
var val T
if cache := getCache(ctx); cache != nil {
if err := cache.Get(ctx, cacheKey, &val); err == nil {
return &val, nil
}
}
db := getDB(ctx)
if err := query(db, &val); err != nil {
return nil, err
}
if cache := getCache(ctx); cache != nil {
_ = cache.Set(ctx, cacheKey, val, ttl)
}
return &val, nil
}
// GetActivePushChannelByName 根据名称获取启用的消息通道 (优先从 Redis 缓存获取)。
func GetActivePushChannelByName(ctx context.Context, name string) (*PushChannel, error) {
return getCachedOrQuery(ctx, "push:channel:active:"+name, activePushChannelCacheTTL, func(db *gorm.DB, dest *PushChannel) error {
return db.Where("name = ? AND enabled = ?", name, true).First(dest).Error
})
}
// DeleteActivePushChannelCache 清理启用消息通道的缓存。
func DeleteActivePushChannelCache(ctx context.Context, name string) {
if cache := getCache(ctx); cache != nil {
_ = cache.Delete(ctx, "push:channel:active:"+name)
}
}
// ListPushEventsRecord returns all push events ordered by creation time descending.
func ListPushEventsRecord(ctx context.Context) ([]PushEvent, error) {
var events []PushEvent
if err := getDB(ctx).Order("created_at DESC").Find(&events).Error; err != nil {
return nil, err
}
return events, nil
}
// GetPushEventByIDRecord loads a push event by primary key.
func GetPushEventByIDRecord(ctx context.Context, id uint64) (PushEvent, error) {
var event PushEvent
if err := getDB(ctx).First(&event, id).Error; err != nil {
return PushEvent{}, err
}
return event, nil
}
// GetPushEventByKeyRecord loads a push event by event key.
func GetPushEventByKeyRecord(ctx context.Context, key string) (PushEvent, error) {
var event PushEvent
if err := getDB(ctx).Where("event_key = ?", key).First(&event).Error; err != nil {
return PushEvent{}, err
}
return event, nil
}
// CountPushEventsByKeyRecord returns how many events use the given event key.
func CountPushEventsByKeyRecord(ctx context.Context, key string) (int64, error) {
var count int64
if err := getDB(ctx).Model(&PushEvent{}).Where("event_key = ?", key).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// CreatePushEventRecord persists a new push event and invalidates cache.
func CreatePushEventRecord(ctx context.Context, event *PushEvent) error {
if err := getDB(ctx).Create(event).Error; err != nil {
return err
}
DeleteActivePushEventCache(ctx, event.EventKey)
return nil
}
// SavePushEventRecord updates a push event and invalidates cache.
func SavePushEventRecord(ctx context.Context, event *PushEvent) error {
if err := getDB(ctx).Save(event).Error; err != nil {
return err
}
DeleteActivePushEventCache(ctx, event.EventKey)
return nil
}
// UpdatePushEventEnabledRecord toggles the enabled flag for a push event.
func UpdatePushEventEnabledRecord(ctx context.Context, event *PushEvent, enabled bool) error {
event.Enabled = enabled
if err := getDB(ctx).Model(event).Update("enabled", enabled).Error; err != nil {
return err
}
DeleteActivePushEventCache(ctx, event.EventKey)
return nil
}
// DeletePushEventRecord removes a push event and invalidates cache.
func DeletePushEventRecord(ctx context.Context, event *PushEvent) error {
if err := getDB(ctx).Delete(event).Error; err != nil {
return err
}
DeleteActivePushEventCache(ctx, event.EventKey)
return nil
}
// ListActivePushEventsByTaskTypeRecord returns enabled events bound to a task type.
func ListActivePushEventsByTaskTypeRecord(ctx context.Context, taskType string) ([]PushEvent, error) {
var events []PushEvent
if err := getDB(ctx).Where("task_type = ? AND enabled = ?", taskType, true).Find(&events).Error; err != nil {
return nil, err
}
return events, nil
}
// GetActivePushEventByKey 获取启用的通知事件 (优先从 Redis 缓存获取)。
func GetActivePushEventByKey(ctx context.Context, key string) (*PushEvent, error) {
return getCachedOrQuery(ctx, "push:event:active:"+key, activePushEventCacheTTL, func(db *gorm.DB, dest *PushEvent) error {
return db.Where("event_key = ? AND enabled = ?", key, true).First(dest).Error
})
}
// DeleteActivePushEventCache 清理启用通知事件的缓存。
func DeleteActivePushEventCache(ctx context.Context, key string) {
if cache := getCache(ctx); cache != nil {
_ = cache.Delete(ctx, "push:event:active:"+key)
}
}
// ListPushHistoriesRecord returns paginated push history records.
func ListPushHistoriesRecord(ctx context.Context, filter PushHistoryListFilter) (int64, []PushHistory, error) {
query := getDB(ctx).Model(&PushHistory{}).Order("created_at DESC")
if filter.EventKey != "" {
query = query.Where("event_key = ?", filter.EventKey)
}
if filter.Status != "" {
query = query.Where("status = ?", filter.Status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return 0, nil, err
}
var results []PushHistory
offset := (filter.Page - 1) * filter.PageSize
if err := query.Offset(offset).Limit(filter.PageSize).Find(&results).Error; err != nil {
return 0, nil, err
}
return total, results, nil
}
// CreatePushHistoryRecord persists a push history audit record.
func CreatePushHistoryRecord(ctx context.Context, history *PushHistory) error {
return getDB(ctx).Create(history).Error
}
// PushHistoryQuery returns a scoped query builder for push histories.
func PushHistoryQuery(ctx context.Context) *gorm.DB {
return getDB(ctx).Model(&PushHistory{})
}
@@ -0,0 +1,324 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package repository
import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/plugins/domain/message_gateway/errs"
"Wavelet/plugins/domain/message_gateway/model"
"context"
"sync"
"time"
"gorm.io/gorm"
)
const (
activePushChannelCacheTTL = 24 * time.Hour
activePushEventCacheTTL = 24 * time.Hour
)
var (
cacheMu sync.RWMutex
cacheSvc contracts.CacheService
)
// SetCacheService sets the cache service singleton.
func SetCacheService(s contracts.CacheService) {
cacheMu.Lock()
defer cacheMu.Unlock()
cacheSvc = s
}
// GetCache resolves the cache service for the current call.
func GetCache(ctx context.Context) contracts.CacheService {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.CacheService](c); err == nil && s != nil {
return s
}
}
cacheMu.RLock()
s := cacheSvc
cacheMu.RUnlock()
return s
}
// ListPushChannelsRecord returns all push channels ordered by creation time descending.
func ListPushChannelsRecord(ctx context.Context) ([]model.PushChannel, error) {
var channels []model.PushChannel
if err := GetDB(ctx).Order("created_at DESC").Find(&channels).Error; err != nil {
return nil, err
}
return channels, nil
}
// GetPushChannelByIDRecord loads a push channel by primary key.
func GetPushChannelByIDRecord(ctx context.Context, id uint64) (model.PushChannel, error) {
var channel model.PushChannel
if err := GetDB(ctx).Where("id = ?", id).First(&channel).Error; err != nil {
return model.PushChannel{}, mapNotFound(err)
}
return channel, nil
}
// GetPushChannelByNameRecord loads a push channel by its unique name.
func GetPushChannelByNameRecord(ctx context.Context, name string) (*model.PushChannel, error) {
var channel model.PushChannel
if err := GetDB(ctx).Where("name = ?", name).First(&channel).Error; err != nil {
return nil, mapNotFound(err)
}
return &channel, nil
}
// CountPushChannelsByNameRecord returns how many channels share the given name.
func CountPushChannelsByNameRecord(ctx context.Context, name string) (int64, error) {
var count int64
if err := GetDB(ctx).Model(&model.PushChannel{}).Where("name = ?", name).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// CreatePushChannelRecord persists a new channel and invalidates cache.
func CreatePushChannelRecord(ctx context.Context, channel *model.PushChannel) error {
if err := GetDB(ctx).Create(channel).Error; err != nil {
return err
}
DeleteActivePushChannelCache(ctx, channel.Name)
return nil
}
// SavePushChannelRecord updates a channel and invalidates cache.
func SavePushChannelRecord(ctx context.Context, channel *model.PushChannel) error {
if err := GetDB(ctx).Save(channel).Error; err != nil {
return err
}
DeleteActivePushChannelCache(ctx, channel.Name)
return nil
}
// DeletePushChannelRecord removes a channel and invalidates cache.
func DeletePushChannelRecord(ctx context.Context, channel *model.PushChannel) error {
if err := GetDB(ctx).Delete(channel).Error; err != nil {
return err
}
DeleteActivePushChannelCache(ctx, channel.Name)
return nil
}
func getCachedOrQuery[T any](ctx context.Context, cacheKey string, ttl time.Duration, query func(db *gorm.DB, dest *T) error) (*T, error) {
var val T
if cache := GetCache(ctx); cache != nil {
if err := cache.Get(ctx, cacheKey, &val); err == nil {
return &val, nil
}
}
db := GetDB(ctx)
if err := query(db, &val); err != nil {
return nil, err
}
if cache := GetCache(ctx); cache != nil {
_ = cache.Set(ctx, cacheKey, val, ttl)
}
return &val, nil
}
// GetActivePushChannelByName loads an enabled push channel, preferring the cache layer.
func GetActivePushChannelByName(ctx context.Context, name string) (*model.PushChannel, error) {
channel, err := getCachedOrQuery(ctx, "push:channel:active:"+name, activePushChannelCacheTTL, func(db *gorm.DB, dest *model.PushChannel) error {
return db.Where("name = ? AND enabled = ?", name, true).First(dest).Error
})
if err != nil {
return nil, mapNotFound(err)
}
return channel, nil
}
// DeleteActivePushChannelCache drops the cached enabled-channel entry.
func DeleteActivePushChannelCache(ctx context.Context, name string) {
if cache := GetCache(ctx); cache != nil {
_ = cache.Delete(ctx, "push:channel:active:"+name)
}
}
// ListPushEventsRecord returns all push events ordered by creation time descending.
func ListPushEventsRecord(ctx context.Context) ([]model.PushEvent, error) {
var events []model.PushEvent
if err := GetDB(ctx).Order("created_at DESC").Find(&events).Error; err != nil {
return nil, err
}
return events, nil
}
// GetPushEventByIDRecord loads a push event by primary key.
func GetPushEventByIDRecord(ctx context.Context, id uint64) (model.PushEvent, error) {
var event model.PushEvent
if err := GetDB(ctx).First(&event, id).Error; err != nil {
return model.PushEvent{}, mapNotFound(err)
}
return event, nil
}
// GetPushEventByKeyRecord loads a push event by event key.
func GetPushEventByKeyRecord(ctx context.Context, key string) (model.PushEvent, error) {
var event model.PushEvent
if err := GetDB(ctx).Where("event_key = ?", key).First(&event).Error; err != nil {
return model.PushEvent{}, mapNotFound(err)
}
return event, nil
}
// CountPushEventsByKeyRecord returns how many events use the given event key.
func CountPushEventsByKeyRecord(ctx context.Context, key string) (int64, error) {
var count int64
if err := GetDB(ctx).Model(&model.PushEvent{}).Where("event_key = ?", key).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// CreatePushEventRecord persists a new push event and invalidates cache.
func CreatePushEventRecord(ctx context.Context, event *model.PushEvent) error {
if err := GetDB(ctx).Create(event).Error; err != nil {
return err
}
DeleteActivePushEventCache(ctx, event.EventKey)
return nil
}
// SavePushEventRecord updates a push event and invalidates cache.
func SavePushEventRecord(ctx context.Context, event *model.PushEvent) error {
if err := GetDB(ctx).Save(event).Error; err != nil {
return err
}
DeleteActivePushEventCache(ctx, event.EventKey)
return nil
}
// UpdatePushEventEnabledRecord toggles the enabled flag for a push event.
func UpdatePushEventEnabledRecord(ctx context.Context, event *model.PushEvent, enabled bool) error {
event.Enabled = enabled
if err := GetDB(ctx).Model(event).Update("enabled", enabled).Error; err != nil {
return err
}
DeleteActivePushEventCache(ctx, event.EventKey)
return nil
}
// DeletePushEventRecord removes a push event and invalidates cache.
func DeletePushEventRecord(ctx context.Context, event *model.PushEvent) error {
if err := GetDB(ctx).Delete(event).Error; err != nil {
return err
}
DeleteActivePushEventCache(ctx, event.EventKey)
return nil
}
// ListActivePushEventsByTaskTypeRecord returns enabled events bound to a task type.
func ListActivePushEventsByTaskTypeRecord(ctx context.Context, taskType string) ([]model.PushEvent, error) {
var events []model.PushEvent
if err := GetDB(ctx).Where("task_type = ? AND enabled = ?", taskType, true).Find(&events).Error; err != nil {
return nil, err
}
return events, nil
}
// GetActivePushEventByKey loads an enabled push event, preferring the cache layer.
func GetActivePushEventByKey(ctx context.Context, key string) (*model.PushEvent, error) {
event, err := getCachedOrQuery(ctx, "push:event:active:"+key, activePushEventCacheTTL, func(db *gorm.DB, dest *model.PushEvent) error {
return db.Where("event_key = ? AND enabled = ?", key, true).First(dest).Error
})
if err != nil {
return nil, mapNotFound(err)
}
return event, nil
}
// DeleteActivePushEventCache drops the cached enabled-event entry.
func DeleteActivePushEventCache(ctx context.Context, key string) {
if cache := GetCache(ctx); cache != nil {
_ = cache.Delete(ctx, "push:event:active:"+key)
}
}
// ListPushHistoriesRecord returns paginated push history records.
func ListPushHistoriesRecord(ctx context.Context, filter model.PushHistoryListFilter) (int64, []model.PushHistory, error) {
query := GetDB(ctx).Model(&model.PushHistory{}).Order("created_at DESC")
if filter.EventKey != "" {
query = query.Where("event_key = ?", filter.EventKey)
}
if filter.Status != "" {
query = query.Where("status = ?", filter.Status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return 0, nil, err
}
var results []model.PushHistory
offset := (filter.Page - 1) * filter.PageSize
if err := query.Offset(offset).Limit(filter.PageSize).Find(&results).Error; err != nil {
return 0, nil, err
}
return total, results, nil
}
// CreatePushHistoryRecord persists a push history audit record.
func CreatePushHistoryRecord(ctx context.Context, history *model.PushHistory) error {
return GetDB(ctx).Create(history).Error
}
// PushHistoryQuery returns a scoped query builder for push histories.
func PushHistoryQuery(ctx context.Context) *gorm.DB {
return GetDB(ctx).Model(&model.PushHistory{})
}
// LoadSMTPConfigRecord reads the SMTP settings owned by the system config table.
func LoadSMTPConfigRecord(ctx context.Context) model.SMTPConfig {
var cfg model.SMTPConfig
var host, port, user, pass string
_ = GetDB(ctx).Table("w_system_configs").Where("key = ?", "smtp_host").Pluck("value", &host).Error
_ = GetDB(ctx).Table("w_system_configs").Where("key = ?", "smtp_port").Pluck("value", &port).Error
_ = GetDB(ctx).Table("w_system_configs").Where("key = ?", "smtp_username").Pluck("value", &user).Error
_ = GetDB(ctx).Table("w_system_configs").Where("key = ?", "smtp_password").Pluck("value", &pass).Error
cfg.Host = host
cfg.Port = port
cfg.Username = user
cfg.Password = pass
return cfg
}
// FindUserByFieldRecord is the user lookup fallback for when the UserService
// contract is not wired yet. field comes from call sites, never from user input.
func FindUserByFieldRecord(ctx context.Context, field string, value any) (*contracts.UserDTO, error) {
db := GetDB(ctx)
if db == nil {
return nil, errs.ErrRecordNotFound
}
var user contracts.UserDTO
if err := db.Table("w_users").Where(field+" = ?", value).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// FindFirstAdminUserRecord is the admin lookup fallback for when the UserService
// contract is not wired yet.
func FindFirstAdminUserRecord(ctx context.Context) (*contracts.UserDTO, error) {
db := GetDB(ctx)
if db == nil {
return nil, errs.ErrRecordNotFound
}
var adminUser contracts.UserDTO
if err := db.Table("w_users").Where("is_admin = ?", true).Order("id ASC").First(&adminUser).Error; err != nil {
return nil, err
}
return &adminUser, nil
}
@@ -0,0 +1,199 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package repository provides data persistence for the message_gateway plugin.
package repository
import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/pkg/idgen"
"Wavelet/plugins/domain/message_gateway/errs"
"Wavelet/plugins/domain/message_gateway/model"
"context"
"errors"
"sync"
"time"
"gorm.io/gorm"
)
var (
dbMu sync.RWMutex
dbSvc contracts.DBService
)
// SetDBServiceForTest injects a DBService for tests. Production wiring must use Apply.
func SetDBServiceForTest(s contracts.DBService) {
SetDBService(s)
}
// SetDBService sets the database service singleton.
func SetDBService(s contracts.DBService) {
dbMu.Lock()
defer dbMu.Unlock()
dbSvc = s
}
// GetDB resolves the persistence handle for the current call, preferring an
// explicitly injected *core.Context before falling back to the plugin singleton.
func GetDB(ctx context.Context) *gorm.DB {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil {
return s.DB(ctx)
}
}
dbMu.RLock()
s := dbSvc
dbMu.RUnlock()
if s != nil {
return s.DB(ctx)
}
return nil
}
// mapNotFound translates GORM's missing-row sentinel into the plugin-level
// errs.ErrRecordNotFound so the service and handler layers stay free of gorm imports.
func mapNotFound(err error) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errs.ErrRecordNotFound
}
return err
}
// CreateMessageChannel inserts a channel row.
func CreateMessageChannel(ctx context.Context, ch *model.MessageChannel) error {
if ch.ID == 0 {
ch.ID = idgen.NextUint64ID()
}
return GetDB(ctx).Create(ch).Error
}
// UpdateMessageChannel saves a channel row.
func UpdateMessageChannel(ctx context.Context, ch *model.MessageChannel) error {
return GetDB(ctx).Save(ch).Error
}
// GetMessageChannel loads a channel by id.
func GetMessageChannel(ctx context.Context, id uint64) (*model.MessageChannel, error) {
var ch model.MessageChannel
if err := GetDB(ctx).Where("id = ?", id).First(&ch).Error; err != nil {
return nil, mapNotFound(err)
}
return &ch, nil
}
// ListMessageChannels returns all channels newest first.
func ListMessageChannels(ctx context.Context) ([]model.MessageChannel, error) {
var rows []model.MessageChannel
if err := GetDB(ctx).Order("id DESC").Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// DeleteMessageChannel removes pairings, bindings, then the channel.
func DeleteMessageChannel(ctx context.Context, id uint64) error {
return GetDB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("channel_id = ?", id).Delete(&model.MessagePairingCode{}).Error; err != nil {
return err
}
if err := tx.Where("channel_id = ?", id).Delete(&model.MessageBinding{}).Error; err != nil {
return err
}
return tx.Delete(&model.MessageChannel{}, id).Error
})
}
// CreateMessageBinding inserts a binding.
func CreateMessageBinding(ctx context.Context, b *model.MessageBinding) error {
if b.ID == 0 {
b.ID = idgen.NextUint64ID()
}
return GetDB(ctx).Create(b).Error
}
// GetBindingByChannelPlatform finds a binding for a platform user on a channel.
func GetBindingByChannelPlatform(ctx context.Context, channelID uint64, platformUserID string) (*model.MessageBinding, error) {
var b model.MessageBinding
err := GetDB(ctx).Where("channel_id = ? AND platform_user_id = ?", channelID, platformUserID).First(&b).Error
if err != nil {
return nil, mapNotFound(err)
}
return &b, nil
}
// ListBindingsByUser lists bindings for a Wavelet user.
func ListBindingsByUser(ctx context.Context, userID uint64) ([]model.MessageBinding, error) {
var rows []model.MessageBinding
if err := GetDB(ctx).Where("user_id = ?", userID).Order("id DESC").Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// GetMessageBinding loads a binding by id.
func GetMessageBinding(ctx context.Context, id uint64) (*model.MessageBinding, error) {
var b model.MessageBinding
if err := GetDB(ctx).Where("id = ?", id).First(&b).Error; err != nil {
return nil, mapNotFound(err)
}
return &b, nil
}
// DeleteMessageBinding deletes a binding by id.
func DeleteMessageBinding(ctx context.Context, id uint64) error {
return GetDB(ctx).Delete(&model.MessageBinding{}, id).Error
}
// UpsertPairingCode reuses an unexpired code for the same channel+platform user.
func UpsertPairingCode(ctx context.Context, channelID uint64, platformUserID, code string, expiresAt time.Time) (*model.MessagePairingCode, error) {
var existing model.MessagePairingCode
err := GetDB(ctx).
Where("channel_id = ? AND platform_user_id = ? AND expires_at > ?", channelID, platformUserID, time.Now()).
First(&existing).Error
if err == nil {
return &existing, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
row := &model.MessagePairingCode{
Code: code,
ChannelID: channelID,
PlatformUserID: platformUserID,
ExpiresAt: expiresAt,
}
if err := GetDB(ctx).Create(row).Error; err != nil {
return nil, err
}
return row, nil
}
// GetPairingCode loads a pairing code by normalized code string.
func GetPairingCode(ctx context.Context, code string) (*model.MessagePairingCode, error) {
var row model.MessagePairingCode
if err := GetDB(ctx).Where("code = ?", code).First(&row).Error; err != nil {
return nil, mapNotFound(err)
}
return &row, nil
}
// DeletePairingCode removes a pairing code.
func DeletePairingCode(ctx context.Context, code string) error {
return GetDB(ctx).Where("code = ?", code).Delete(&model.MessagePairingCode{}).Error
}
// DeleteExpiredPairingCodes removes expired pairing rows.
func DeleteExpiredPairingCodes(ctx context.Context) error {
return GetDB(ctx).Where("expires_at <= ?", time.Now()).Delete(&model.MessagePairingCode{}).Error
}
// ListEnabledMessageChannels returns enabled channels.
func ListEnabledMessageChannels(ctx context.Context) ([]model.MessageChannel, error) {
var rows []model.MessageChannel
if err := GetDB(ctx).Where("enabled = ?", true).Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
@@ -1,52 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import (
"Wavelet/pkg/logger"
"context"
"sync"
)
// Runner manages lifecycle for long-lived channel adapters (WebSocket, long-polling, etc.).
type Runner struct {
mu sync.Mutex
running bool
cancel context.CancelFunc
}
// GlobalRunner is the default global runner instance.
var GlobalRunner = &Runner{}
// Start starts all background long-lived channel runners.
func Start(ctx context.Context) error {
GlobalRunner.mu.Lock()
defer GlobalRunner.mu.Unlock()
if GlobalRunner.running {
return nil
}
runCtx, cancel := context.WithCancel(ctx)
GlobalRunner.cancel = cancel
GlobalRunner.running = true
logger.InfoF(runCtx, "[MessageGateway] Starting bot channel runners...")
return nil
}
// Stop stops the channel runner.
func Stop() {
GlobalRunner.mu.Lock()
defer GlobalRunner.mu.Unlock()
if !GlobalRunner.running {
return
}
if GlobalRunner.cancel != nil {
GlobalRunner.cancel()
}
GlobalRunner.running = false
}
@@ -1,77 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package message_gateway
import (
"Wavelet/pkg/config"
"Wavelet/pkg/util"
"crypto/sha256"
"encoding/hex"
"encoding/json"
)
// CredentialKey is AES-256 hex derived from the session secret.
func CredentialKey() string {
secret := ""
if config.Config != nil {
secret = config.Config.App.SessionSecret
}
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
// EncryptCredentials encrypts a credential map as JSON.
func EncryptCredentials(creds map[string]string) (string, error) {
if creds == nil {
creds = map[string]string{}
}
raw, err := json.Marshal(creds)
if err != nil {
return "", err
}
return util.Encrypt(CredentialKey(), string(raw))
}
// DecryptCredentials decrypts a credential map.
func DecryptCredentials(ciphertext string) (map[string]string, error) {
if ciphertext == "" {
return map[string]string{}, nil
}
plain, err := util.Decrypt(CredentialKey(), ciphertext)
if err != nil {
return nil, err
}
var out map[string]string
if err := json.Unmarshal([]byte(plain), &out); err != nil {
return nil, err
}
if out == nil {
out = map[string]string{}
}
return out, nil
}
// ParseExtra decodes optional extra JSON into a string map.
func ParseExtra(raw string) map[string]string {
if raw == "" {
return map[string]string{}
}
var out map[string]string
if err := json.Unmarshal([]byte(raw), &out); err != nil || out == nil {
return map[string]string{}
}
return out
}
// EncodeExtra encodes extra fields as JSON.
func EncodeExtra(extra map[string]string) string {
if extra == nil {
return ""
}
raw, err := json.Marshal(extra)
if err != nil {
return ""
}
return string(raw)
}
@@ -0,0 +1,310 @@
// 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: "password", Required: true},
{Key: "api_base", Type: "text", Required: false},
},
},
{
Type: model.MessageChannelTypeQQ,
Fields: []model.Field{
{Key: "app_id", Type: "text", 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:]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,395 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package service implements domain business logic and channel runners for message_gateway.
package service
import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/pkg/config"
"Wavelet/pkg/logger"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/message_gateway/errs"
"Wavelet/plugins/domain/message_gateway/model"
"Wavelet/plugins/domain/message_gateway/repository"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strconv"
"strings"
"sync"
"time"
"unicode"
)
// Handler processes one inbound message.
type Handler func(ctx context.Context, msg model.InboundMessage) error
// Factory constructs a Channel from decrypted config.
type Factory func(cfg model.ChannelConfig, onInbound Handler) (Channel, error)
// Channel is one connected messaging adapter.
type Channel interface {
Type() string
Connect(ctx context.Context) error
Disconnect(ctx context.Context) error
Send(ctx context.Context, to model.Recipient, msg model.OutboundMessage) error
Capabilities() model.Capability
}
var (
factoriesMu sync.RWMutex
factories = map[string]Factory{}
)
// Register stores a channel factory under typ.
func Register(typ string, fn Factory) {
factoriesMu.Lock()
defer factoriesMu.Unlock()
factories[typ] = fn
}
// Lookup returns a previously registered factory.
func Lookup(typ string) (Factory, bool) {
factoriesMu.RLock()
defer factoriesMu.RUnlock()
fn, ok := factories[typ]
return fn, ok
}
// CodeAlphabet excludes easily confused runes 0/O/1/I.
const CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
// CodeLength is the raw pairing code size.
const CodeLength = 8
// GenerateCode returns an 8-character pairing code.
func GenerateCode() (string, error) {
buf := make([]byte, CodeLength)
if _, err := rand.Read(buf); err != nil {
return "", err
}
out := make([]byte, CodeLength)
for i, b := range buf {
out[i] = CodeAlphabet[int(b)%len(CodeAlphabet)]
}
return string(out), nil
}
// NormalizeCode strips separators and uppercases.
func NormalizeCode(s string) string {
var b strings.Builder
for _, r := range s {
if r == '-' || unicode.IsSpace(r) {
continue
}
b.WriteRune(unicode.ToUpper(r))
}
return b.String()
}
// FormatCode renders ABCD-EFGH.
func FormatCode(s string) string {
s = NormalizeCode(s)
if len(s) != CodeLength {
return s
}
return s[:4] + "-" + s[4:]
}
// CredentialKey is AES-256 hex derived from the session secret.
func CredentialKey() string {
secret := ""
if config.Config != nil {
secret = config.Config.App.SessionSecret
}
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
// EncryptCredentials encrypts a credential map as JSON.
func EncryptCredentials(creds map[string]string) (string, error) {
if creds == nil {
creds = map[string]string{}
}
raw, err := json.Marshal(creds)
if err != nil {
return "", err
}
return util.Encrypt(CredentialKey(), string(raw))
}
// DecryptCredentials decrypts a credential map.
func DecryptCredentials(ciphertext string) (map[string]string, error) {
if ciphertext == "" {
return map[string]string{}, nil
}
plain, err := util.Decrypt(CredentialKey(), ciphertext)
if err != nil {
return nil, err
}
var out map[string]string
if err := json.Unmarshal([]byte(plain), &out); err != nil {
return nil, err
}
if out == nil {
out = map[string]string{}
}
return out, nil
}
// ParseExtra decodes optional extra JSON into a string map.
func ParseExtra(raw string) map[string]string {
if raw == "" {
return map[string]string{}
}
var out map[string]string
if err := json.Unmarshal([]byte(raw), &out); err != nil || out == nil {
return map[string]string{}
}
return out
}
// EncodeExtra encodes extra fields as JSON.
func EncodeExtra(extra map[string]string) string {
if extra == nil {
return ""
}
raw, err := json.Marshal(extra)
if err != nil {
return ""
}
return string(raw)
}
// Runner manages lifecycle for long-lived channel adapters (WebSocket, long-polling, etc.).
type Runner struct {
mu sync.Mutex
running bool
cancel context.CancelFunc
}
// GlobalRunner is the default global runner instance.
var GlobalRunner = &Runner{}
// Start starts all background long-lived channel runners.
func Start(ctx context.Context) error {
GlobalRunner.mu.Lock()
defer GlobalRunner.mu.Unlock()
if GlobalRunner.running {
return nil
}
runCtx, cancel := context.WithCancel(ctx)
GlobalRunner.cancel = cancel
GlobalRunner.running = true
logger.InfoF(runCtx, "[MessageGateway] Starting bot channel runners...")
return nil
}
// Stop stops the channel runner.
func Stop() {
GlobalRunner.mu.Lock()
defer GlobalRunner.mu.Unlock()
if !GlobalRunner.running {
return
}
if GlobalRunner.cancel != nil {
GlobalRunner.cancel()
}
GlobalRunner.running = false
}
// Cordis contract singletons consumed by service layer.
var (
cacheMu sync.RWMutex
cacheSvc contracts.CacheService
taskMu sync.RWMutex
taskSvc contracts.TaskService
userMu sync.RWMutex
userSvc contracts.UserService
)
// SetCacheService sets the cache service.
func SetCacheService(s contracts.CacheService) {
cacheMu.Lock()
defer cacheMu.Unlock()
cacheSvc = s
}
// SetTaskService sets the task service.
func SetTaskService(s contracts.TaskService) {
taskMu.Lock()
defer taskMu.Unlock()
taskSvc = s
}
// SetUserService sets the user service.
func SetUserService(s contracts.UserService) {
userMu.Lock()
defer userMu.Unlock()
userSvc = s
}
// GetCache resolves the cache service for the context.
func GetCache(ctx context.Context) contracts.CacheService {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.CacheService](c); err == nil && s != nil {
return s
}
}
cacheMu.RLock()
s := cacheSvc
cacheMu.RUnlock()
return s
}
// GetTaskService returns the task service.
func GetTaskService() contracts.TaskService {
taskMu.RLock()
defer taskMu.RUnlock()
return taskSvc
}
// GetUserService resolves the user service for the context.
func GetUserService(ctx context.Context) contracts.UserService {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.UserService](c); err == nil && s != nil {
return s
}
}
userMu.RLock()
s := userSvc
userMu.RUnlock()
return s
}
// BindChannel consumes a pairing code and binds the platform identity to the user.
func BindChannel(ctx context.Context, userID uint64, req model.BindRequest) (model.BindingDTO, error) {
channelID, err := strconv.ParseUint(strings.TrimSpace(req.ChannelID), 10, 64)
if err != nil || channelID == 0 {
return model.BindingDTO{}, errs.ErrChannelIDRequired
}
code := NormalizeCode(req.Code)
if code == "" {
return model.BindingDTO{}, errs.ErrCodeInvalid
}
pairing, err := repository.GetPairingCode(ctx, code)
if err != nil {
if errors.Is(err, errs.ErrRecordNotFound) {
return model.BindingDTO{}, errs.ErrCodeInvalid
}
return model.BindingDTO{}, err
}
if !pairing.ExpiresAt.After(time.Now()) {
return model.BindingDTO{}, errs.ErrCodeInvalid
}
if pairing.ChannelID != channelID {
return model.BindingDTO{}, errs.ErrChannelMismatch
}
ch, err := repository.GetMessageChannel(ctx, channelID)
if err != nil {
if errors.Is(err, errs.ErrRecordNotFound) {
return model.BindingDTO{}, errs.ErrCodeInvalid
}
return model.BindingDTO{}, err
}
if !ch.Enabled {
return model.BindingDTO{}, errs.ErrChannelDisabled
}
existing, err := repository.GetBindingByChannelPlatform(ctx, channelID, pairing.PlatformUserID)
if err != nil && !errors.Is(err, errs.ErrRecordNotFound) {
return model.BindingDTO{}, err
}
if err == nil && existing != nil {
if existing.UserID != userID {
return model.BindingDTO{}, errs.ErrPlatformAlreadyBound
}
_ = repository.DeletePairingCode(ctx, pairing.Code)
return ToBindingDTO(existing, ch), nil
}
row := &model.MessageBinding{
UserID: userID,
ChannelID: channelID,
PlatformUserID: pairing.PlatformUserID,
}
if err := repository.CreateMessageBinding(ctx, row); err != nil {
return model.BindingDTO{}, err
}
if err := repository.DeletePairingCode(ctx, pairing.Code); err != nil {
return model.BindingDTO{}, err
}
return ToBindingDTO(row, ch), nil
}
// ListEnabledPublicChannels returns the channels a user may bind to.
func ListEnabledPublicChannels(ctx context.Context) ([]model.PublicChannelDTO, error) {
rows, err := repository.ListEnabledMessageChannels(ctx)
if err != nil {
return nil, err
}
out := make([]model.PublicChannelDTO, 0, len(rows))
for _, row := range rows {
out = append(out, model.PublicChannelDTO{ID: row.ID, Name: row.Name, Type: row.Type})
}
return out, nil
}
// ListUserBindings returns the binding rows of one user enriched with channel info.
func ListUserBindings(ctx context.Context, userID uint64) ([]model.BindingDTO, error) {
rows, err := repository.ListBindingsByUser(ctx, userID)
if err != nil {
return nil, err
}
out := make([]model.BindingDTO, 0, len(rows))
for i := range rows {
ch, err := repository.GetMessageChannel(ctx, rows[i].ChannelID)
if err != nil {
out = append(out, ToBindingDTO(&rows[i], nil))
continue
}
out = append(out, ToBindingDTO(&rows[i], ch))
}
return out, nil
}
// UnbindChannel removes a binding owned by the given user.
func UnbindChannel(ctx context.Context, userID, bindingID uint64) error {
row, err := repository.GetMessageBinding(ctx, bindingID)
if err != nil {
if errors.Is(err, errs.ErrRecordNotFound) {
return errs.ErrBindingNotFound
}
return err
}
if row.UserID != userID {
return errs.ErrBindingForbidden
}
return repository.DeleteMessageBinding(ctx, bindingID)
}
// ToBindingDTO projects a binding row and its optional channel onto the user DTO.
func ToBindingDTO(row *model.MessageBinding, ch *model.MessageChannel) model.BindingDTO {
dto := model.BindingDTO{
ID: row.ID,
UserID: row.UserID,
ChannelID: row.ChannelID,
PlatformUserID: row.PlatformUserID,
CreatedAt: row.CreatedAt,
}
if ch != nil {
dto.ChannelName = ch.Name
dto.ChannelType = ch.Type
}
return dto
}