feat(channels/webhook): 入站回调渠道插件(JSON/Form 解析 + HMAC 签名 + InboundHandler 供 gateway 挂载,主动推送不支持)
This commit is contained in:
1 parent
7adaf490c7
commit
0b036645bb
5 files changed
+1193
No files matched your search
@@ -0,0 +1,272 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user