Files
Memoh/internal/channel/adapters/feishu/webhook_handler.go
T
BBQ f376a2abe3 fix(channel): add wechatoa webhook delivery and proxy config (#356)
Unify webhook handling across channel adapters and add the WeChat Official Account channel so inbound routing and replies work without platform-specific handlers. Add adapter-scoped proxy support and stable config field ordering so restricted network environments can deliver WeChat and Telegram messages reliably.
2026-04-10 21:26:11 +08:00

167 lines
5.6 KiB
Go

package feishu
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/labstack/echo/v4"
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
"github.com/memohai/memoh/internal/channel"
)
const webhookMaxBodyBytes int64 = 1 << 20 // 1 MiB
// HandleWebhook processes Feishu/Lark event-subscription callbacks.
func (a *FeishuAdapter) HandleWebhook(ctx context.Context, cfg channel.ChannelConfig, handler channel.InboundHandler, r *http.Request, w http.ResponseWriter) error {
if a == nil {
return echo.NewHTTPError(http.StatusInternalServerError, "feishu adapter is nil")
}
if handler == nil {
return echo.NewHTTPError(http.StatusInternalServerError, "feishu inbound handler is nil")
}
if r.Method == http.MethodGet {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
return nil
}
if r.Method != http.MethodPost {
return echo.NewHTTPError(http.StatusMethodNotAllowed, "method not allowed")
}
feishuCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if feishuCfg.InboundMode != inboundModeWebhook {
return echo.NewHTTPError(http.StatusBadRequest, "feishu inbound_mode is not webhook")
}
payload, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBodyBytes+1))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("read body: %v", err))
}
if int64(len(payload)) > webhookMaxBodyBytes {
return echo.NewHTTPError(http.StatusRequestEntityTooLarge, fmt.Sprintf("payload too large: max %d bytes", webhookMaxBodyBytes))
}
botOpenID := a.resolveBotOpenID(context.WithoutCancel(ctx), cfg)
eventDispatcher := dispatcher.NewEventDispatcher(feishuCfg.VerificationToken, feishuCfg.EncryptKey)
webhookReq, err := inspectWebhookRequest(ctx, eventDispatcher, r, payload)
if err != nil {
return err
}
if err := validateWebhookCallbackAuth(webhookReq, feishuCfg); err != nil {
return err
}
if challengeResp := buildWebhookChallengeResponse(webhookReq); challengeResp != nil {
return writeEventResponse(w, challengeResp)
}
eventDispatcher.OnP2MessageReceiveV1(func(_ context.Context, event *larkim.P2MessageReceiveV1) error {
msg := extractFeishuInbound(event, botOpenID, a.logger)
if strings.TrimSpace(msg.Message.PlainText()) == "" && len(msg.Message.Attachments) == 0 {
return nil
}
a.enrichSenderProfile(ctx, cfg, event, &msg)
a.enrichQuotedMessage(ctx, cfg, &msg, botOpenID)
msg.BotID = cfg.BotID
return handler(ctx, cfg, msg)
})
resp := eventDispatcher.Handle(ctx, &larkevent.EventReq{
Header: r.Header,
Body: payload,
RequestURI: r.RequestURI,
})
if resp == nil {
w.WriteHeader(http.StatusOK)
return nil
}
return writeEventResponse(w, resp)
}
func inspectWebhookRequest(ctx context.Context, eventDispatcher *dispatcher.EventDispatcher, req *http.Request, payload []byte) (larkevent.EventFuzzy, error) {
plainPayload, err := parseWebhookPayload(ctx, eventDispatcher, req, payload)
if err != nil {
return larkevent.EventFuzzy{}, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid feishu webhook payload: %v", err))
}
var fuzzy larkevent.EventFuzzy
if err := json.Unmarshal([]byte(plainPayload), &fuzzy); err != nil {
return larkevent.EventFuzzy{}, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid feishu webhook payload: %v", err))
}
return fuzzy, nil
}
func validateWebhookCallbackAuth(fuzzy larkevent.EventFuzzy, cfg Config) error {
expectedToken := strings.TrimSpace(cfg.VerificationToken)
encryptKey := strings.TrimSpace(cfg.EncryptKey)
if expectedToken == "" && encryptKey == "" {
return echo.NewHTTPError(http.StatusBadRequest, "feishu webhook requires encrypt_key or verification_token")
}
requestToken := webhookRequestToken(fuzzy)
if expectedToken == "" {
return nil
}
if requestToken == "" || requestToken != expectedToken {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid feishu webhook token")
}
return nil
}
func buildWebhookChallengeResponse(fuzzy larkevent.EventFuzzy) *larkevent.EventResp {
if webhookRequestType(fuzzy) != larkevent.ReqTypeChallenge {
return nil
}
return &larkevent.EventResp{
Header: http.Header{larkevent.ContentTypeHeader: []string{larkevent.DefaultContentType}},
Body: []byte(fmt.Sprintf(larkevent.ChallengeResponseFormat, fuzzy.Challenge)),
StatusCode: http.StatusOK,
}
}
func webhookRequestToken(fuzzy larkevent.EventFuzzy) string {
requestToken := strings.TrimSpace(fuzzy.Token)
if fuzzy.Header != nil && strings.TrimSpace(fuzzy.Header.Token) != "" {
requestToken = strings.TrimSpace(fuzzy.Header.Token)
}
return requestToken
}
func webhookRequestType(fuzzy larkevent.EventFuzzy) larkevent.ReqType {
return larkevent.ReqType(strings.TrimSpace(fuzzy.Type))
}
func parseWebhookPayload(ctx context.Context, eventDispatcher *dispatcher.EventDispatcher, req *http.Request, payload []byte) (string, error) {
cipherPayload, err := eventDispatcher.ParseReq(ctx, &larkevent.EventReq{
Header: req.Header,
Body: payload,
RequestURI: req.RequestURI,
})
if err != nil {
return "", err
}
return eventDispatcher.DecryptEvent(ctx, cipherPayload)
}
func writeEventResponse(w http.ResponseWriter, resp *larkevent.EventResp) error {
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
if len(resp.Body) == 0 {
return nil
}
_, err := w.Write(resp.Body) //nolint:gosec // Response body is generated by the verified Feishu SDK event response.
return err
}