Files
Cyrene_v2/plugins/channels/kook/webhook.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

315 lines
10 KiB
Go

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},
})
}