mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
f376a2abe3
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.
167 lines
5.6 KiB
Go
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
|
|
}
|