Files
Cyrene_v2/plugins/channels/webhook/adapter.go
T
Aska 617157262b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channel): KOOK Webhook(WH) 接收模式——与 WS 配置切换(ReceiveMode)
- kook.Config 加 ReceiveMode("ws"默认/"webhook") + WebhookPath(默认/webhook/kook) + WebhookVerifyToken + WebhookEncryptKey(AES-256-CBC)
- 按权威文档(Reference/kaiheila webhook.md)实现: 无X-Signature HMAC, 真实校验=d.verify_token比对(不匹配403)+可选AES解密+zlib解压+challenge回显
- webhook.go: InboundHandler+WebhookPath, POST→zlib解压→AES解密→verify_token校验→challenge回显→归一UnifiedMessage→SetMessageHandler; WS模式Connect不拨网关仅标记connected
- channelbridge/webhookAdapter 接口改用 WebhookPath(); WebhookHandlers 收KOOK WH(ws模式跳过); 既有webhook通道补WebhookPath照常挂载
- namedAdapter 转发InboundHandler/WebhookPath; kook env兜底 +4个CHANNEL_KOOK_*键
- 测试: verify_token 403/challenge/zlib/AES往返/WebhookPath按模式/WH不拨gateway/WebhookHandlers收KOOK; 全仓build/vet/test绿
- 待真token端到端实测(需主人提供CHANNEL_KOOK_TOKEN+开发者后台切WebHook配回调URL)
2026-09-07 14:10:03 +08:00

278 lines
9.6 KiB
Go

// Package webhook implements the webhook inbound channel adapter (design/08
// §4). A webhook channel receives external HTTP callbacks (JSON or form posts)
// and normalizes them into UnifiedMessage so the runtime can reason about them
// like any other channel.
//
// It is inbound-only: the adapter never pushes messages out, so
// Capabilities.Proactive is false and SendMessage/SendProactive return
// adapter.ErrUnsupported. The gateway mounts the exposed InboundHandler at the
// configured endpoint path (M8). HMAC-SHA256 signature verification is opt-in
// via Config.Secret.
//
// The adapter lives behind adapter.PlatformAdapter so the runtime can treat it
// uniformly with the other channels declared in design/08 (qq-obv11,
// weixin-ilink, telegram). Configuration is sourced externally and injected at
// construction; no values are hard-coded beyond safe fallbacks.
package webhook
import (
"context"
"fmt"
"log/slog"
"net/http"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// PlatformName is the identifier returned by Name and stamped onto every
// inbound UnifiedMessage so consumers can attribute the origin.
PlatformName = "webhook"
// source is the logger provenance tag required by the logging rule.
source = "plugins/channels/webhook"
// defaultEndpointPath is used when Config.EndpointPath is empty.
defaultEndpointPath = "/webhook"
// defaultSignatureHeader carries the HMAC signature when Config.Secret is set.
defaultSignatureHeader = "X-Webhook-Signature"
// defaultMaxBodyBytes caps an inbound callback payload at 1 MiB to protect
// against oversized bodies, per the streaming/no-ReadAll-large-files rule.
defaultMaxBodyBytes = 1 << 20
)
// Config configures the webhook inbound adapter. It is decoded from the channel
// config map via adapter.DecodeConfig; every field is optional and meaningfully
// defaulted so an empty config yields a usable endpoint.
type Config struct {
// EndpointPath is the route the gateway mounts the inbound handler at.
EndpointPath string `json:"endpointPath"`
// ChannelID identifies this webhook channel; when set it is reported by
// ListChannels. Empty means no channel is pre-registered.
ChannelID string `json:"channelId"`
// ChannelName is the human-readable channel name for ListChannels.
ChannelName string `json:"channelName"`
// Secret enables HMAC-SHA256 signature verification when non-empty. The
// signature is computed over the raw request body and passed in the header
// named by SignatureHeader. Empty disables verification.
Secret string `json:"secret"`
// SignatureHeader names the header carrying the signature. Defaults to
// "X-Webhook-Signature" when empty.
SignatureHeader string `json:"signatureHeader"`
// Methods are the accepted HTTP methods. Defaults to ["POST"].
Methods []string `json:"methods"`
}
// Adapter implements adapter.PlatformAdapter for the webhook channel. It is an
// inbound-only adapter: callbacks arrive through the InboundHandler and are
// delivered to the installed message handler (or the emit callback captured by
// ReadMessages). No external session is held, so Connect/Disconnect only track
// lifecycle state.
type Adapter struct {
log *slog.Logger
client *http.Client
cfg Config
mu sync.RWMutex
connected bool
handler adapter.MessageHandler
emit func(adapter.UnifiedMessage)
}
// Compile-time assertion that Adapter satisfies the channel contract.
var _ adapter.PlatformAdapter = (*Adapter)(nil)
// Option adjusts an Adapter at construction time.
type Option func(*Adapter)
// WithConfig sets the adapter configuration.
func WithConfig(cfg Config) Option {
return func(a *Adapter) { a.cfg = cfg }
}
// WithClient injects the HTTP client used for any outbound call. The webhook
// adapter is inbound-only and never uses it, but the field is kept so callers
// that later add outbound delivery do not change the constructor.
func WithClient(c *http.Client) Option {
return func(a *Adapter) { a.client = c }
}
// WithLogger sets the structured logger used by the adapter. A nil logger
// falls back to a default tagged logger so logging never panics.
func WithLogger(l *slog.Logger) Option {
return func(a *Adapter) { a.log = l }
}
// New builds a webhook adapter. It defaults the HTTP client to
// http.DefaultClient and a source-tagged structured logger, applies opts, then
// normalizes the configuration so a minimal config still yields a working
// endpoint.
func New(opts ...Option) *Adapter {
a := &Adapter{
client: http.DefaultClient,
log: logger.WithSource(logger.New(logger.Options{Format: string(logger.FormatText), Level: "info"}), source),
}
for _, o := range opts {
o(a)
}
a.normalize()
return a
}
// normalize fills zero-valued config with safe defaults so an empty Config is
// usable. It is called once at construction; the config is immutable afterward.
func (a *Adapter) normalize() {
if a.cfg.EndpointPath == "" {
a.cfg.EndpointPath = defaultEndpointPath
}
if a.cfg.SignatureHeader == "" {
a.cfg.SignatureHeader = defaultSignatureHeader
}
if len(a.cfg.Methods) == 0 {
a.cfg.Methods = []string{http.MethodPost}
}
if a.client == nil {
a.client = http.DefaultClient
}
if a.log == nil {
a.log = logger.WithSource(logger.New(logger.Options{Format: string(logger.FormatText), Level: "info"}), source)
}
}
// WebhookPath returns the route the gateway mounts the inbound handler at. It is
// the normalized EndpointPath (default "/webhook"), satisfying the channel
// bridge's webhookAdapter contract.
func (a *Adapter) WebhookPath() string { return a.cfg.EndpointPath }
// Name returns the platform identifier "webhook".
func (a *Adapter) Name() string { return PlatformName }
// Config returns a snapshot of the current configuration as a plain map for
// inspection or persistence.
func (a *Adapter) Config() map[string]any {
return map[string]any{
"endpointPath": a.cfg.EndpointPath,
"channelId": a.cfg.ChannelID,
"channelName": a.cfg.ChannelName,
"secret": a.cfg.Secret,
"signatureHeader": a.cfg.SignatureHeader,
"methods": a.cfg.Methods,
}
}
// Connect marks the adapter connected (idempotent). No external session is
// established; the gateway registers the route by mounting InboundHandler, so
// Connect only transitions lifecycle state for the caller's benefit.
func (a *Adapter) Connect(_ context.Context) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.connected {
return nil
}
a.connected = true
a.log.Info("webhook channel connected", "endpointPath", a.cfg.EndpointPath)
return nil
}
// Disconnect tears down the adapter (idempotent): it clears the connected flag
// and drops any installed handler/emit so no callback is delivered after a
// shutdown.
func (a *Adapter) Disconnect(_ context.Context) error {
a.mu.Lock()
defer a.mu.Unlock()
if !a.connected {
return nil
}
a.connected = false
a.handler = nil
a.emit = nil
a.log.Info("webhook channel disconnected")
return nil
}
// IsConnected reports whether the adapter is currently connected.
func (a *Adapter) IsConnected() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.connected
}
// HealthCheck verifies the adapter is usable. As an inbound-only adapter that
// holds no session, it only reports the lifecycle state.
func (a *Adapter) HealthCheck(_ context.Context) error {
if !a.IsConnected() {
return fmt.Errorf("webhook: %w", adapter.ErrNotConnected)
}
return nil
}
// ReadMessages installs emit as a fallback delivery target, then blocks until
// ctx is canceled. Delivery is push-style via the message handler, matching the
// documented behaviour for push adapters; emit is only exercised when no
// handler is installed.
func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMessage)) {
a.mu.Lock()
a.emit = emit
a.mu.Unlock()
<-ctx.Done()
a.mu.Lock()
a.emit = nil
a.mu.Unlock()
}
// SendMessage is unsupported: the webhook channel is inbound-only and has no
// outbound delivery path. It returns adapter.ErrUnsupported.
func (a *Adapter) SendMessage(_ context.Context, _ adapter.UnifiedResponse) error {
return fmt.Errorf("webhook: send message: %w", adapter.ErrUnsupported)
}
// SendProactive is unsupported for the same reason as SendMessage:
// Capabilities.Proactive is false. It returns adapter.ErrUnsupported.
func (a *Adapter) SendProactive(_ context.Context, _ adapter.UnifiedMessage) error {
return fmt.Errorf("webhook: proactive push: %w", adapter.ErrUnsupported)
}
// SetMessageHandler installs the callback receiving push-style inbound
// messages. A nil fn clears the handler.
func (a *Adapter) SetMessageHandler(fn adapter.MessageHandler) {
a.mu.Lock()
defer a.mu.Unlock()
a.handler = fn
}
// Capabilities declares what the webhook channel supports. Because it is
// inbound-only, Proactive is false; image is reported so consumers know media
// URLs may appear in inbound messages.
func (a *Adapter) Capabilities() adapter.Capabilities {
return adapter.Capabilities{
MaxMessageLength: 8192,
Markdown: true,
Image: true,
Voice: false,
Reaction: false,
TypingHint: false,
RecommendBurstMax: 1,
Proactive: false,
}.Normalize()
}
// ListChannels returns the channel declared by Config.ChannelID when present,
// otherwise an empty list: the webhook endpoint is a single generic channel
// whose identity only becomes concrete per inbound message.
func (a *Adapter) ListChannels(_ context.Context) ([]adapter.ChannelInfo, error) {
if a.cfg.ChannelID == "" {
return nil, nil
}
return []adapter.ChannelInfo{
{
ChannelID: a.cfg.ChannelID,
ChannelType: adapter.ChannelDirect,
ChannelName: a.cfg.ChannelName,
},
}, nil
}