273 lines
9.4 KiB
Go
273 lines
9.4 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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|