// 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 }