mirror of
https://github.com/sky22333/qqbot.git
synced 2026-09-05 15:37:14 +08:00
优化sdk
This commit is contained in:
@@ -96,9 +96,20 @@ func main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`target_id` 可选;不传时 SDK 会自动按 `target_type` 回填最近采集目标。
|
||||||
|
`target_type` 与 `target_id` 都不传时,SDK 会使用最近一次采集到的目标类型与 ID。
|
||||||
|
|
||||||
|
如需在短生命周期任务中关闭采集器,可使用:
|
||||||
|
|
||||||
|
```go
|
||||||
|
client, err := qqbot.NewWithOptions(cfg, qqbot.ClientOptions{
|
||||||
|
StartCollector: false,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
## 4. 目标采集
|
## 4. 目标采集
|
||||||
|
|
||||||
启动服务后,用自己的 QQ 给机器人发消息,系统会自动采集目标并写入 `data/known_targets.json`。
|
启动服务后,用自己的 QQ 给机器人发消息,系统会自动采集目标并写入 `targets.file_path` 对应的文件(默认 `data/targets.json`)。
|
||||||
可通过 `GET /api/v1/targets` 查看。
|
可通过 `GET /api/v1/targets` 查看。
|
||||||
|
|
||||||
## 5. 常用命令
|
## 5. 常用命令
|
||||||
|
|||||||
+4
-25
@@ -10,10 +10,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/sky22333/qqbot/config"
|
"github.com/sky22333/qqbot/config"
|
||||||
"github.com/sky22333/qqbot/internal/collector"
|
"github.com/sky22333/qqbot/internal/bootstrap"
|
||||||
"github.com/sky22333/qqbot/internal/httpserver"
|
"github.com/sky22333/qqbot/internal/httpserver"
|
||||||
"github.com/sky22333/qqbot/internal/notifier"
|
|
||||||
"github.com/sky22333/qqbot/internal/targets"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -31,30 +29,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel}))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel}))
|
||||||
|
|
||||||
flushInterval, err := cfg.TargetsFlushInterval()
|
components, err := bootstrap.New(cfg, logger, bootstrap.Options{StartCollector: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
targetStore, err := targets.NewStore(cfg.Targets.FilePath, cfg.Targets.MaxRecords, flushInterval)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
targetCollector, err := collector.New(cfg, logger, targetStore)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
targetCollector.Start()
|
|
||||||
logger.Info("采集器已启动")
|
logger.Info("采集器已启动")
|
||||||
|
|
||||||
n, err := notifier.New(cfg, logger)
|
server, err := httpserver.New(cfg, logger, components.Notifier, components.Targets)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
n.SetTargetStore(targetStore)
|
|
||||||
|
|
||||||
server, err := httpserver.New(cfg, logger, n, targetStore)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -83,8 +64,6 @@ func main() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_ = server.Shutdown(ctx)
|
_ = server.Shutdown(ctx)
|
||||||
targetCollector.Stop()
|
components.Close()
|
||||||
n.Close()
|
|
||||||
_ = targetStore.Close()
|
|
||||||
logger.Info("服务已退出")
|
logger.Info("服务已退出")
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-17
@@ -107,26 +107,57 @@ func Default() Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Load(path string) (Config, error) {
|
func Load(path string) (Config, error) {
|
||||||
|
return loadWithValidator(path, Config.ValidateForServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadSDK(path string) (Config, error) {
|
||||||
|
return loadWithValidator(path, Config.ValidateForSDK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadWithValidator(path string, validator func(Config) error) (Config, error) {
|
||||||
cfg := Default()
|
cfg := Default()
|
||||||
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := validator(cfg); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) Validate() error {
|
func (c Config) ValidateForSDK() error {
|
||||||
|
return c.validateCommon()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) ValidateForServer() error {
|
||||||
|
if err := c.validateCommon(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.Server.ListenAddr) == "" {
|
||||||
|
return errors.New("server.listen_addr 不能为空")
|
||||||
|
}
|
||||||
|
if c.Server.MaxBodyBytes <= 0 {
|
||||||
|
return errors.New("server.max_body_bytes 必须大于 0")
|
||||||
|
}
|
||||||
|
if _, err := c.ReadTimeout(); err != nil {
|
||||||
|
return fmt.Errorf("server.read_timeout 无效: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := c.WriteTimeout(); err != nil {
|
||||||
|
return fmt.Errorf("server.write_timeout 无效: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := c.ShutdownTimeout(); err != nil {
|
||||||
|
return fmt.Errorf("server.shutdown_timeout 无效: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) validateCommon() error {
|
||||||
if strings.TrimSpace(c.QQBot.AppID) == "" {
|
if strings.TrimSpace(c.QQBot.AppID) == "" {
|
||||||
return errors.New("qqbot.app_id 不能为空")
|
return errors.New("qqbot.app_id 不能为空")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(c.QQBot.ClientSecret) == "" {
|
if strings.TrimSpace(c.QQBot.ClientSecret) == "" {
|
||||||
return errors.New("qqbot.client_secret 不能为空")
|
return errors.New("qqbot.client_secret 不能为空")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(c.Server.ListenAddr) == "" {
|
|
||||||
return errors.New("server.listen_addr 不能为空")
|
|
||||||
}
|
|
||||||
if c.Dispatch.QueueSize <= 0 {
|
if c.Dispatch.QueueSize <= 0 {
|
||||||
return errors.New("dispatch.queue_size 必须大于 0")
|
return errors.New("dispatch.queue_size 必须大于 0")
|
||||||
}
|
}
|
||||||
@@ -139,21 +170,9 @@ func (c Config) Validate() error {
|
|||||||
if c.Dispatch.RetryBackoffMS <= 0 {
|
if c.Dispatch.RetryBackoffMS <= 0 {
|
||||||
return errors.New("dispatch.retry_backoff_ms 必须大于 0")
|
return errors.New("dispatch.retry_backoff_ms 必须大于 0")
|
||||||
}
|
}
|
||||||
if c.Server.MaxBodyBytes <= 0 {
|
|
||||||
return errors.New("server.max_body_bytes 必须大于 0")
|
|
||||||
}
|
|
||||||
if _, err := c.RequestTimeout(); err != nil {
|
if _, err := c.RequestTimeout(); err != nil {
|
||||||
return fmt.Errorf("qqbot.request_timeout 无效: %w", err)
|
return fmt.Errorf("qqbot.request_timeout 无效: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := c.ReadTimeout(); err != nil {
|
|
||||||
return fmt.Errorf("server.read_timeout 无效: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := c.WriteTimeout(); err != nil {
|
|
||||||
return fmt.Errorf("server.write_timeout 无效: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := c.ShutdownTimeout(); err != nil {
|
|
||||||
return fmt.Errorf("server.shutdown_timeout 无效: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := c.EnqueueTimeout(); err != nil {
|
if _, err := c.EnqueueTimeout(); err != nil {
|
||||||
return fmt.Errorf("dispatch.enqueue_timeout 无效: %w", err)
|
return fmt.Errorf("dispatch.enqueue_timeout 无效: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadSDKAllowsServerOnlyConstraintsMissing(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "sdk.toml")
|
||||||
|
content := `
|
||||||
|
[qqbot]
|
||||||
|
app_id = "123"
|
||||||
|
client_secret = "secret"
|
||||||
|
|
||||||
|
[server]
|
||||||
|
listen_addr = ""
|
||||||
|
max_body_bytes = 0
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("写入配置文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := LoadSDK(path); err != nil {
|
||||||
|
t.Fatalf("LoadSDK 不应因 server 字段失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Load(path); err == nil {
|
||||||
|
t.Fatalf("Load 应校验 server 字段并返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package qqbot
|
|
||||||
|
|
||||||
import "github.com/sky22333/qqbot/config"
|
|
||||||
|
|
||||||
func LoadConfig(path string) (Config, error) {
|
|
||||||
return config.Load(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultConfig() Config {
|
|
||||||
return config.Default()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/sky22333/qqbot/config"
|
||||||
|
"github.com/sky22333/qqbot/internal/collector"
|
||||||
|
"github.com/sky22333/qqbot/internal/notifier"
|
||||||
|
"github.com/sky22333/qqbot/internal/targets"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
StartCollector bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Components struct {
|
||||||
|
Notifier *notifier.Notifier
|
||||||
|
Targets *targets.Store
|
||||||
|
Collector *collector.Collector
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.Config, logger *slog.Logger, opts Options) (*Components, error) {
|
||||||
|
flushInterval, err := cfg.TargetsFlushInterval()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
targetStore, err := targets.NewStore(cfg.Targets.FilePath, cfg.Targets.MaxRecords, flushInterval)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n, err := notifier.New(cfg, logger)
|
||||||
|
if err != nil {
|
||||||
|
_ = targetStore.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n.SetTargetStore(targetStore)
|
||||||
|
c := &Components{
|
||||||
|
Notifier: n,
|
||||||
|
Targets: targetStore,
|
||||||
|
}
|
||||||
|
if !opts.StartCollector {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
targetCollector, err := collector.New(cfg, logger, targetStore)
|
||||||
|
if err != nil {
|
||||||
|
n.Close()
|
||||||
|
_ = targetStore.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
targetCollector.Start()
|
||||||
|
c.Collector = targetCollector
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Components) Close() {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Collector != nil {
|
||||||
|
c.Collector.Stop()
|
||||||
|
}
|
||||||
|
if c.Notifier != nil {
|
||||||
|
c.Notifier.Close()
|
||||||
|
}
|
||||||
|
if c.Targets != nil {
|
||||||
|
_ = c.Targets.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package notifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sky22333/qqbot/config"
|
||||||
|
"github.com/sky22333/qqbot/internal/targets"
|
||||||
|
"github.com/sky22333/qqbot/message"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnqueueFillTargetFromLatestByType(t *testing.T) {
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.Dispatch.Workers = 0
|
||||||
|
cfg.Targets.FilePath = filepath.Join(t.TempDir(), "targets.json")
|
||||||
|
store, err := targets.NewStore(cfg.Targets.FilePath, cfg.Targets.MaxRecords, 10*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建目标存储失败: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
if err := store.Upsert(message.TargetC2C, "user-001", "m1", "hello"); err != nil {
|
||||||
|
t.Fatalf("写入目标失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := New(cfg, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建 notifier 失败: %v", err)
|
||||||
|
}
|
||||||
|
defer n.Close()
|
||||||
|
n.SetTargetStore(store)
|
||||||
|
|
||||||
|
_, err = n.Enqueue(context.Background(), message.PushRequest{
|
||||||
|
TargetType: message.TargetC2C,
|
||||||
|
Content: "通知",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("应自动补全 target_id,但返回错误: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnqueueWithoutStoreReturnsTargetIDError(t *testing.T) {
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.Dispatch.Workers = 0
|
||||||
|
|
||||||
|
n, err := New(cfg, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建 notifier 失败: %v", err)
|
||||||
|
}
|
||||||
|
defer n.Close()
|
||||||
|
|
||||||
|
_, err = n.Enqueue(context.Background(), message.PushRequest{
|
||||||
|
TargetType: message.TargetC2C,
|
||||||
|
Content: "通知",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("预期返回 target_id 不能为空")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "target_id 不能为空") {
|
||||||
|
t.Fatalf("错误信息不符合预期: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,27 +6,37 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/sky22333/qqbot/config"
|
"github.com/sky22333/qqbot/config"
|
||||||
"github.com/sky22333/qqbot/internal/notifier"
|
"github.com/sky22333/qqbot/internal/bootstrap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
notifier *notifier.Notifier
|
components *bootstrap.Components
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientOptions struct {
|
||||||
|
StartCollector bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config) (*Client, error) {
|
func New(cfg Config) (*Client, error) {
|
||||||
if err := cfg.Validate(); err != nil {
|
return NewWithOptions(cfg, ClientOptions{
|
||||||
|
StartCollector: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWithOptions(cfg Config, opts ClientOptions) (*Client, error) {
|
||||||
|
if err := cfg.ValidateForSDK(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
n, err := notifier.New(cfg, logger)
|
components, err := bootstrap.New(cfg, logger, bootstrap.Options{StartCollector: opts.StartCollector})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Client{notifier: n}, nil
|
return &Client{components: components}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFromConfigFile(path string) (*Client, error) {
|
func NewFromConfigFile(path string) (*Client, error) {
|
||||||
cfg, err := config.Load(path)
|
cfg, err := config.LoadSDK(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -34,17 +44,17 @@ func NewFromConfigFile(path string) (*Client, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Send(ctx context.Context, req PushRequest) (PushResult, error) {
|
func (c *Client) Send(ctx context.Context, req PushRequest) (PushResult, error) {
|
||||||
return c.notifier.Send(ctx, req)
|
return c.components.Notifier.Send(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Enqueue(ctx context.Context, req PushRequest) (string, error) {
|
func (c *Client) Enqueue(ctx context.Context, req PushRequest) (string, error) {
|
||||||
return c.notifier.Enqueue(ctx, req)
|
return c.components.Notifier.Enqueue(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetStatus(requestID string) (DeliveryStatus, bool) {
|
func (c *Client) GetStatus(requestID string) (DeliveryStatus, bool) {
|
||||||
return c.notifier.GetStatus(requestID)
|
return c.components.Notifier.GetStatus(requestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Close() {
|
func (c *Client) Close() {
|
||||||
c.notifier.Close()
|
c.components.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package qqbot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sky22333/qqbot/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewWithOptionsWithoutCollector(t *testing.T) {
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.QQBot.AppID = "123"
|
||||||
|
cfg.QQBot.ClientSecret = "secret"
|
||||||
|
cfg.Server.ListenAddr = ""
|
||||||
|
cfg.Server.MaxBodyBytes = 0
|
||||||
|
cfg.Targets.FilePath = filepath.Join(t.TempDir(), "targets.json")
|
||||||
|
|
||||||
|
client, err := NewWithOptions(cfg, ClientOptions{StartCollector: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建客户端失败: %v", err)
|
||||||
|
}
|
||||||
|
client.Close()
|
||||||
|
}
|
||||||
+3
-1
@@ -6,6 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sky22333/qqbot/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSendNotificationWithConfig(t *testing.T) {
|
func TestSendNotificationWithConfig(t *testing.T) {
|
||||||
@@ -19,7 +21,7 @@ func TestSendNotificationWithConfig(t *testing.T) {
|
|||||||
configPath = "configs/config.toml"
|
configPath = "configs/config.toml"
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := LoadConfig(configPath)
|
cfg, err := config.LoadSDK(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("加载配置失败: %v", err)
|
t.Fatalf("加载配置失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user