package kook // This file implements the KOOK webhook inbound mode (DevDocs/design/08 §4). // When ReceiveMode == "webhook" the adapter does not connect the gateway // WebSocket; instead the Cyrene gateway mounts InboundHandler at WebhookPath and // KOOK POSTs event callbacks to it (KOOK's webhook and websocket modes are // mutually exclusive). // // KOOK webhook protocol (docs/zh-cn/webhook.md): the callback body is event JSON // {s,d,sn} — zlib-compressed by default unless the callback URL carries // compress=0. Optionally the whole body is a {"encrypt": ...} AES-256-CBC // wrapper. A challenge request (d.type=255, d.channel_type=WEBHOOK_CHALLENGE, // d.challenge=...) must be answered within 1s by echoing {challenge}. Event // authenticity is proven by comparing d.verify_token to the developer-center // verify token; a mismatch is rejected with 403. There is no header-based HMAC // signature for KOOK webhook — verification is the in-body verify_token plus // optional AES encryption. // // Decay of the same path as the WebSocket events: the parsed event d payload is // normalized through toUnifiedMessage and dispatched via SetMessageHandler/emit, // so inbound ordering, actor metadata and group/private routing are identical for // both modes. import ( "bytes" "compress/zlib" "context" "crypto/aes" "crypto/cipher" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" ) // webhookChallengeChannel is KOOK's marker for a challenge request. const webhookChallengeChannel = "WEBHOOK_CHALLENGE" // errVerifyTokenMismatch is returned when an event's d.verify_token does not // match the configured WebhookVerifyToken. Callers map it to HTTP 403. var errVerifyTokenMismatch = errors.New("kook: webhook verify token mismatch") // InboundHandler returns the http.Handler the gateway mounts at WebhookPath to // receive KOOK webhook callbacks. It is safe to serve concurrently once built // from the adapter's immutable config. func (a *Adapter) InboundHandler() http.Handler { return http.HandlerFunc(a.serveWebhook) } // WebhookPath returns the route to mount when ReceiveMode == "webhook", else "" // so the channel bridge skips the adapter (a websocket-mode KOOK exposes no // inbound HTTP handler). func (a *Adapter) WebhookPath() string { if !a.cfg.IsWebhook() { return "" } if a.cfg.WebhookPath != "" { return a.cfg.WebhookPath } return defaultWebhookPath } // serveWebhook is the HTTP entry point for a KOOK callback: it validates the // method, decompresses/decrypts, verifies the token, and either answers a // challenge or normalizes the event into a UnifiedMessage. func (a *Adapter) serveWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeWebhookError(w, http.StatusMethodNotAllowed, "method_not_allowed", "kook: webhook method not allowed") return } body, err := readWebhookBody(w, r) if err != nil { writeWebhookError(w, http.StatusBadRequest, "bad_request", err.Error()) return } payload, err := a.decodeWebhookPayload(body) if err != nil { writeWebhookError(w, http.StatusBadRequest, "bad_request", err.Error()) return } challenge, err := a.handleWebhookEvent(r.Context(), payload) if err != nil { status, code := http.StatusBadRequest, "bad_request" if errors.Is(err, errVerifyTokenMismatch) { // Verify-token failure is a security rejection: 403, not 400. status, code = http.StatusForbidden, "forbidden" } writeWebhookError(w, status, code, err.Error()) return } if challenge != "" { writeWebhookChallenge(w, challenge) return } writeWebhookOK(w) } // readWebhookBody reads and size-caps the request body so a misconfigured caller // cannot exhaust memory. func readWebhookBody(w http.ResponseWriter, r *http.Request) ([]byte, error) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, defaultMaxBodyBytes)) if err != nil { return nil, fmt.Errorf("kook: webhook read body: %w", err) } return body, nil } // decodeWebhookPayload returns the JSON event payload from a callback body. It // first zlib-decompresses when the body carries the zlib header (KOOK compresses // by default), then AES-256-CBC-decrypts when an encrypt wrapper is present and // an encrypt key is configured. func (a *Adapter) decodeWebhookPayload(body []byte) ([]byte, error) { payload := body if isZlib(payload) { dec, err := zlibDecode(payload) if err != nil { return nil, fmt.Errorf("kook: webhook decompress: %w", err) } payload = dec } if a.cfg.WebhookEncryptKey != "" { if enc, ok := encryptWrapper(payload); ok { dec, err := decryptKOOK(enc, a.cfg.WebhookEncryptKey) if err != nil { return nil, fmt.Errorf("kook: webhook decrypt: %w", err) } payload = dec } } if len(payload) == 0 { return nil, errors.New("kook: webhook empty body") } return payload, nil } // handleWebhookEvent verifies the token, answers a challenge when present, and // otherwise normalizes the event into a UnifiedMessage and dispatches it. It // returns the challenge value (to be echoed) or "" for a processed event. func (a *Adapter) handleWebhookEvent(ctx context.Context, payload []byte) (string, error) { var env kookSignal if err := json.Unmarshal(payload, &env); err != nil { return "", fmt.Errorf("kook: webhook decode envelope: %w", err) } if err := a.verifyWebhookToken(env.D); err != nil { return "", err } var ch struct { Challenge string `json:"challenge"` } if json.Unmarshal(env.D, &ch) == nil && ch.Challenge != "" { return ch.Challenge, nil } msg, ok := a.toUnifiedMessage(env.D, payload) if !ok { // Non-chat notification (system, etc.): acknowledge without a reply. return "", nil } if err := msg.Validate(); err != nil { a.log.Warn("invalid webhook message dropped", "err", err) return "", nil } a.dispatch(ctx, msg) return "", nil } // verifyWebhookToken checks d.verify_token against the configured token when one // is set; an empty configured token disables verification. A mismatch (or a // payload that omits verify_token while verification is enabled) is rejected. func (a *Adapter) verifyWebhookToken(d json.RawMessage) error { want := a.cfg.WebhookVerifyToken if want == "" { return nil } var v struct { VerifyToken string `json:"verify_token"` } if err := json.Unmarshal(d, &v); err != nil { return fmt.Errorf("kook: webhook token read: %w", err) } if v.VerifyToken != want { return fmt.Errorf("%w", errVerifyTokenMismatch) } return nil } // isZlib reports whether b begins with a zlib header (0x78), the KOOK default // compression framing. JSON payloads start with '{' (0x7b), so this never // misclassifies a plain callback body. func isZlib(b []byte) bool { return len(b) >= 3 && b[0] == 0x78 } // zlibDecode inflates a zlib stream. func zlibDecode(b []byte) ([]byte, error) { zr, err := zlib.NewReader(bytes.NewReader(b)) if err != nil { return nil, err } defer func() { _ = zr.Close() }() out, err := io.ReadAll(zr) if err != nil { return nil, err } return out, nil } // encryptWrapper returns the encrypted blob when the body is the {"encrypt": ...} // wrapper KOOK uses when message encryption is enabled. func encryptWrapper(payload []byte) (string, bool) { var w struct { Encrypt string `json:"encrypt"` } if err := json.Unmarshal(payload, &w); err != nil || w.Encrypt == "" { return "", false } return w.Encrypt, true } // decryptKOOK decrypts an encrypted KOOK callback body. The encrypt value is // base64(iv || base64(ciphertext)); the key is the Encrypt Key right-padded with // NUL to 32 bytes. The cipher is AES-256-CBC with PKCS7 padding. func decryptKOOK(encrypt, key string) ([]byte, error) { raw, err := base64.StdEncoding.DecodeString(encrypt) if err != nil { return nil, err } if len(raw) <= aes.BlockSize { return nil, errors.New("kook: webhook encrypted body too short") } iv := raw[:aes.BlockSize] ct, err := base64.StdEncoding.DecodeString(string(raw[aes.BlockSize:])) if err != nil { return nil, err } if len(ct) == 0 || len(ct)%aes.BlockSize != 0 { return nil, errors.New("kook: webhook ciphertext length not a block multiple") } block, err := aes.NewCipher(padKey(key, 32)) if err != nil { return nil, err } plain := make([]byte, len(ct)) cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, ct) return pkcs7Unpad(plain) } // padKey returns key as a right-NUL-padded byte slice of exactly size bytes, // truncating an over-long key to size. func padKey(key string, size int) []byte { out := make([]byte, 0, size) for i := 0; i < len(key) && i < size; i++ { out = append(out, key[i]) } for len(out) < size { out = append(out, 0) } return out } // pkcs7Unpad strips the PKCS7 padding from decryptKOOK's output. func pkcs7Unpad(in []byte) ([]byte, error) { if len(in) == 0 { return nil, errors.New("kook: webhook empty plaintext") } padding := int(in[len(in)-1]) if padding == 0 || padding > aes.BlockSize || padding > len(in) { return nil, errors.New("kook: webhook invalid padding") } for i := len(in) - padding; i < len(in); i++ { if int(in[i]) != padding { return nil, errors.New("kook: webhook invalid padding") } } return in[:len(in)-padding], nil } // webhookChallengeBody is the response KOOK expects for a challenge request. type webhookChallengeBody struct { Challenge string `json:"challenge"` } // writeWebhookChallenge echoes the challenge value within the 1s window KOOK // allows for the handshake. func writeWebhookChallenge(w http.ResponseWriter, challenge string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(webhookChallengeBody{Challenge: challenge}) } // writeWebhookOK acknowledges a processed event with HTTP 200 within the 1s // window (KOOK retries up to 5 times on failure). func writeWebhookOK(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"ok"}`)) } // writeWebhookError writes a JSON error envelope with the given status/code. func writeWebhookError(w http.ResponseWriter, status int, code, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{ "error": map[string]any{"code": code, "message": msg}, }) }