feat(core): bind request services and implement registered tasks

Wire plugin services through Bind/InjectFrom and AppContext so HTTP and
workers resolve dependencies after Apply. Register TaskHandler objects
with persisted results, and implement send_email_code, mail:send,
cleanup_inactive_users, and dispatch_bot_msg.
This commit is contained in:
ryan
2026-09-02 16:59:00 +08:00
parent 30bbe965bf
commit 4f50f6a8f9
49 changed files with 1406 additions and 499 deletions
@@ -28,13 +28,15 @@ var (
// 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 = "********"
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"
ErrBotDispatchTextRequired = "message text is required"
ErrBotChannelNotRegistered = "channel adapter is not registered"
MaskedSecret = "********"
ErrLoginRequired = "login required"
ErrInvalidBindingID = "invalid binding id"
@@ -10,6 +10,8 @@ import (
"Wavelet/core/extpoints"
"Wavelet/pkg/ginutil"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/message_gateway/channels/qq"
"Wavelet/plugins/domain/message_gateway/channels/telegram"
"Wavelet/plugins/domain/message_gateway/handler"
"Wavelet/plugins/domain/message_gateway/model"
"Wavelet/plugins/domain/message_gateway/repository"
@@ -94,37 +96,13 @@ func (p *Plugin) Apply(ctx *core.Context) error {
if err := ctx.Config().Bind("app", &cfg); err == nil && cfg.SessionSecret != "" {
service.SetCredentialSecret(cfg.SessionSecret)
}
// 0. Bind DBService, CacheService, TaskService, UserService
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
repository.SetDBService(db)
} else {
core.When[contracts.DBService](ctx, func(db contracts.DBService) {
repository.SetDBService(db)
})
}
if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil {
core.Bind[contracts.DBService](ctx, repository.SetDBService)
core.Bind[contracts.CacheService](ctx, func(cache contracts.CacheService) {
repository.SetCacheService(cache)
service.SetCacheService(cache)
} else {
core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) {
repository.SetCacheService(cache)
service.SetCacheService(cache)
})
}
if taskSvc, err := core.Inject[contracts.TaskService](ctx); err == nil && taskSvc != nil {
service.SetTaskService(taskSvc)
} else {
core.When[contracts.TaskService](ctx, func(taskSvc contracts.TaskService) {
service.SetTaskService(taskSvc)
})
}
if uSvc, err := core.Inject[contracts.UserService](ctx); err == nil && uSvc != nil {
service.SetUserService(uSvc)
} else {
core.When[contracts.UserService](ctx, func(uSvc contracts.UserService) {
service.SetUserService(uSvc)
})
}
})
core.Bind[contracts.TaskService](ctx, service.SetTaskService)
core.Bind[contracts.UserService](ctx, service.SetUserService)
ctx.OnDispose(func() error {
repository.SetDBService(nil)
repository.SetCacheService(nil)
@@ -159,6 +137,9 @@ func (p *Plugin) Apply(ctx *core.Context) error {
// 4. Register Admin Push HTTP Routes
handler.RegisterAdminPushRoutes(ctx.Router().Group("/api/v1/admin"), loginMW, adminMW)
service.Register(model.MessageChannelTypeTelegram, telegram.New)
service.Register(model.MessageChannelTypeQQ, qq.New)
const defaultTaskRetry = 3
pushHandler := &service.PushHandler{}
@@ -179,15 +160,8 @@ func (p *Plugin) Apply(ctx *core.Context) error {
return pushHandler.Execute(c, payload)
}, extpoints.WithTaskMeta(service.SendNotificationMeta), extpoints.WithTaskRetry(defaultTaskRetry))
ctx.Task().Register("message_gateway:dispatch_bot_msg", func(_ context.Context, _ []byte) error {
return nil
},
extpoints.WithTaskType("dispatch_bot_msg"),
extpoints.WithTaskName("分发 Bot 消息"),
extpoints.WithTaskDescription("异步处理与分发 Bot 下行消息"),
extpoints.WithTaskCategory("messaging"),
extpoints.WithTaskQueue("default"),
)
ctx.Task().Register(service.TaskDispatchBotMsg, &service.BotDispatchHandler{},
extpoints.WithTaskMeta(service.BotDispatchMeta))
ctx.Task().Register("message_gateway:cleanup_pairing_codes", func(c context.Context, _ []byte) error {
return repository.DeleteExpiredPairingCodes(c)
@@ -132,6 +132,15 @@ func ListBindingsByUser(ctx context.Context, userID uint64) ([]model.MessageBind
return rows, nil
}
// ListBindingsByChannel lists bindings on one messaging channel.
func ListBindingsByChannel(ctx context.Context, channelID uint64) ([]model.MessageBinding, error) {
var rows []model.MessageBinding
if err := GetDB(ctx).Where("channel_id = ?", channelID).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
@@ -27,14 +27,14 @@ func ListDefinitions() []model.Definition {
{
Type: model.MessageChannelTypeTelegram,
Fields: []model.Field{
{Key: "token", Type: "password", Required: true},
{Key: "api_base", Type: "text", Required: false},
{Key: "token", Type: model.TypePassword, Required: true},
{Key: "api_base", Type: model.TypeText, Required: false},
},
},
{
Type: model.MessageChannelTypeQQ,
Fields: []model.Field{
{Key: "app_id", Type: "text", Required: true},
{Key: "app_id", Type: model.TypeText, Required: true},
{Key: "client_secret", Type: "password", Required: true},
},
},
@@ -0,0 +1,190 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/message_gateway/errs"
"Wavelet/plugins/domain/message_gateway/model"
"Wavelet/plugins/domain/message_gateway/repository"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
)
const (
// TaskDispatchBotMsg is the queue pattern for bot downlink dispatch.
TaskDispatchBotMsg = "message_gateway:dispatch_bot_msg"
// TaskTypeDispatchBotMsg is the admin type identifier for bot downlink dispatch.
TaskTypeDispatchBotMsg = "dispatch_bot_msg"
taskQueueDefault = "default"
taskParamTypeString = "string"
paramNameText = "text"
)
// BotDispatchMeta describes the bot downlink dispatch task.
var BotDispatchMeta = contracts.TaskMetaDTO{
Type: TaskTypeDispatchBotMsg,
AsynqTask: TaskDispatchBotMsg,
Name: "分发 Bot 消息",
DisplayName: "分发 Bot 消息",
Description: "向已绑定的平台账号异步下发 Bot 文本消息",
Category: "messaging",
Queue: taskQueueDefault,
Retryable: true,
Params: []contracts.TaskParamDTO{
{Name: paramNameText, Label: "消息内容", Type: model.TypeText, Required: true, Placeholder: "要发送的文本", Description: "下发给绑定用户的文本"},
{Name: "channel_id", Label: "频道 ID", Type: taskParamTypeString, Required: false, Placeholder: "留空表示全部启用频道", Description: "仅向指定频道的绑定发送"},
{Name: "user_id", Label: "用户 ID", Type: taskParamTypeString, Required: false, Placeholder: "留空表示频道下全部绑定", Description: "仅向指定 Wavelet 用户的绑定发送"},
},
}
type botDispatchPayload struct {
Text string `json:"text"`
ChannelID uint64 `json:"channel_id,string"`
UserID uint64 `json:"user_id,string"`
}
// BotDispatchHandler sends a text message through enabled bot channels.
type BotDispatchHandler struct{}
// ValidatePayload requires a non-empty message body.
func (h *BotDispatchHandler) ValidatePayload(payload []byte) ([]byte, error) {
p, err := parseBotDispatchPayload(payload)
if err != nil {
return nil, err
}
return json.Marshal(p)
}
// Execute delivers the text to matching channel bindings.
func (h *BotDispatchHandler) Execute(ctx context.Context, payload []byte) (*contracts.TaskResultDTO, error) {
p, err := parseBotDispatchPayload(payload)
if err != nil {
return nil, err
}
channels, err := repository.ListEnabledMessageChannels(ctx)
if err != nil {
return nil, err
}
if p.ChannelID != 0 {
filtered := channels[:0]
for i := range channels {
if channels[i].ID == p.ChannelID {
filtered = append(filtered, channels[i])
}
}
channels = filtered
if len(channels) == 0 {
return nil, errors.New(errs.ErrChannelNotFound)
}
}
sent := 0
failed := 0
for i := range channels {
n, ferr := dispatchOnChannel(ctx, &channels[i], p.UserID, p.Text)
sent += n
failed += ferr
}
msg := fmt.Sprintf("Bot 消息已尝试发送,成功 %d,失败 %d", sent, failed)
if svc := GetTaskService(ctx); svc != nil {
svc.AppendLog(ctx, "%s", msg)
}
if sent == 0 && failed > 0 {
return nil, errors.New(msg)
}
return &contracts.TaskResultDTO{Message: msg}, nil
}
func parseBotDispatchPayload(payload []byte) (botDispatchPayload, error) {
var p botDispatchPayload
if len(payload) > 0 {
if err := json.Unmarshal(payload, &p); err != nil {
return p, fmt.Errorf("%s: %w", errs.ErrInvalidJSONFormat, err)
}
}
p.Text = strings.TrimSpace(p.Text)
if p.Text == "" {
return p, errors.New(errs.ErrBotDispatchTextRequired)
}
return p, nil
}
func dispatchOnChannel(ctx context.Context, row *model.MessageChannel, userID uint64, text string) (sent, failed int) {
factory, ok := Lookup(row.Type)
if !ok {
logger.ErrorF(ctx, "bot dispatch: %s type=%s", errs.ErrBotChannelNotRegistered, row.Type)
return 0, 1
}
cfg, err := channelConfigFromRow(row)
if err != nil {
logger.ErrorF(ctx, "bot dispatch: decode channel %d: %v", row.ID, err)
return 0, 1
}
ch, err := factory(cfg, nil)
if err != nil {
logger.ErrorF(ctx, "bot dispatch: create adapter %d: %v", row.ID, err)
return 0, 1
}
if err := ch.Connect(ctx); err != nil {
logger.ErrorF(ctx, "bot dispatch: connect channel %d: %v", row.ID, err)
return 0, 1
}
defer func() { _ = ch.Disconnect(ctx) }()
bindings, err := repository.ListBindingsByChannel(ctx, row.ID)
if err != nil {
logger.ErrorF(ctx, "bot dispatch: list bindings %d: %v", row.ID, err)
return 0, 1
}
for i := range bindings {
if userID != 0 && bindings[i].UserID != userID {
continue
}
to := model.Recipient{
ChatID: bindings[i].PlatformUserID,
PlatformUserID: bindings[i].PlatformUserID,
}
if err := ch.Send(ctx, to, model.OutboundMessage{Text: text}); err != nil {
logger.ErrorF(ctx, "bot dispatch: send channel=%d user=%d: %v", row.ID, bindings[i].UserID, err)
failed++
continue
}
sent++
}
return sent, failed
}
func channelConfigFromRow(row *model.MessageChannel) (model.ChannelConfig, error) {
creds, err := DecryptCredentials(row.Credentials)
if err != nil {
return model.ChannelConfig{}, err
}
if creds == nil {
creds = map[string]string{}
}
if creds["bot_token"] == "" && creds["token"] != "" {
creds["bot_token"] = creds["token"]
}
if creds["app_secret"] == "" && creds["client_secret"] != "" {
creds["app_secret"] = creds["client_secret"]
}
extra := ParseExtra(row.Extra)
if extra["base_url"] == "" && creds["api_base"] != "" {
extra["base_url"] = creds["api_base"]
}
return model.ChannelConfig{
ID: row.ID,
Type: row.Type,
Name: row.Name,
Credentials: creds,
Extra: extra,
}, nil
}
@@ -0,0 +1,47 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service_test
import (
"Wavelet/plugins/domain/message_gateway/service"
"context"
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"Wavelet/plugins/domain/message_gateway/model"
"Wavelet/plugins/domain/message_gateway/repository"
)
type dispatchTestDB struct{ db *gorm.DB }
func (m *dispatchTestDB) GORM() *gorm.DB { return m.db }
func (m *dispatchTestDB) DB(ctx context.Context) *gorm.DB { return m.db.WithContext(ctx) }
func (m *dispatchTestDB) Named(_ string) *gorm.DB { return m.db }
func TestBotDispatchValidatePayload(t *testing.T) {
h := &service.BotDispatchHandler{}
_, err := h.ValidatePayload([]byte(`{}`))
require.Error(t, err)
_, err = h.ValidatePayload([]byte(`{"text":"hello"}`))
require.NoError(t, err)
}
func TestBotDispatchNoChannels(t *testing.T) {
testDB, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "dispatch.db")), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, testDB.AutoMigrate(&model.MessageChannel{}, &model.MessageBinding{}))
repository.SetDBServiceForTest(&dispatchTestDB{db: testDB})
t.Cleanup(func() { repository.SetDBServiceForTest(nil) })
h := &service.BotDispatchHandler{}
res, err := h.Execute(context.Background(), []byte(`{"text":"hello"}`))
require.NoError(t, err)
require.NotNil(t, res)
assert.Contains(t, res.Message, "成功 0")
}
@@ -52,10 +52,12 @@ func GetBuiltInEvents() []model.EventMetadata {
// PushRegistryAdapter adapts contracts.PushRegistry onto the built-in event store.
type PushRegistryAdapter struct{}
// RegisterBuiltInEvent records a built-in push event definition.
func (PushRegistryAdapter) RegisterBuiltInEvent(meta contracts.PushEventMeta) {
RegisterBuiltInEvent(eventMetadataFromContract(meta))
}
// SyncEvents persists registered built-in events into the database.
func (PushRegistryAdapter) SyncEvents(ctx context.Context) error {
return SyncEvents(ctx)
}
@@ -108,7 +110,7 @@ func ListPushEvents(ctx context.Context) ([]model.PushEvent, error) {
// CreatePushEvent stores a push event configuration for a built-in event or task type.
func CreatePushEvent(ctx context.Context, req model.CreatePushEventRequest) (model.PushEvent, error) {
eventKey, eventName, defaultTemplateBytes, err := GetEventInfo(req)
eventKey, eventName, defaultTemplateBytes, err := GetEventInfo(ctx, req)
if err != nil {
return model.PushEvent{}, err
}
@@ -650,10 +652,10 @@ func FindBuiltInEvent(key string) (model.EventMetadata, bool) {
// GetEventInfo derives the event key, display name and default template for a
// task-completion based event or a registered built-in event key.
func GetEventInfo(req model.CreatePushEventRequest) (string, string, []byte, error) {
func GetEventInfo(ctx context.Context, req model.CreatePushEventRequest) (string, string, []byte, error) {
if req.TaskType != "" {
taskName := req.TaskType
if taskSvc := GetTaskService(); taskSvc != nil {
if taskSvc := GetTaskService(ctx); taskSvc != nil {
if meta, ok := taskSvc.GetTaskMeta(req.TaskType); ok {
taskName = meta.DisplayName
}
@@ -694,7 +696,7 @@ func EnqueuePushTask(ctx context.Context, payload model.SendPayload) error {
if err != nil {
return err
}
if taskSvc := GetTaskService(); taskSvc != nil {
if taskSvc := GetTaskService(ctx); taskSvc != nil {
_, err = taskSvc.Dispatch(ctx, "send_notification", payloadBytes, "system")
return err
}
@@ -749,13 +751,13 @@ var SendNotificationMeta = contracts.TaskMetaDTO{
Category: "push",
SupportsTime: false,
MaxRetry: 3,
Queue: "default",
Queue: taskQueueDefault,
Retryable: true,
Params: []contracts.TaskParamDTO{
{
Name: "event_key",
Label: "事件标识",
Type: "string",
Type: taskParamTypeString,
Required: true,
Placeholder: "admin_login",
Description: "事件标识 (如 admin_login)",
@@ -763,7 +765,7 @@ var SendNotificationMeta = contracts.TaskMetaDTO{
{
Name: "target",
Label: "目标接收者",
Type: "string",
Type: taskParamTypeString,
Required: false,
Description: "目标接收者",
},
@@ -251,10 +251,8 @@ func SetUserService(s contracts.UserService) {
// 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
}
if s, err := core.InjectFrom[contracts.CacheService](ctx); err == nil && s != nil {
return s
}
cacheMu.RLock()
s := cacheSvc
@@ -263,7 +261,10 @@ func GetCache(ctx context.Context) contracts.CacheService {
}
// GetTaskService returns the task service.
func GetTaskService() contracts.TaskService {
func GetTaskService(ctx context.Context) contracts.TaskService {
if s, err := core.InjectFrom[contracts.TaskService](ctx); err == nil && s != nil {
return s
}
taskMu.RLock()
defer taskMu.RUnlock()
return taskSvc
@@ -271,10 +272,8 @@ func GetTaskService() contracts.TaskService {
// 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
}
if s, err := core.InjectFrom[contracts.UserService](ctx); err == nil && s != nil {
return s
}
userMu.RLock()
s := userSvc