- 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)
315 lines
10 KiB
Go
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},
|
|
})
|
|
}
|