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.
This commit is contained in:
BBQ
2026-04-10 21:26:11 +08:00
committed by GitHub
parent 4d3f2de7e2
commit f376a2abe3
47 changed files with 2361 additions and 315 deletions
@@ -16,18 +16,6 @@ import (
const testWebhookConfigID = "cfg-1"
type fakeWebhookStore struct {
configs []channel.ChannelConfig
err error
}
func (s *fakeWebhookStore) ListConfigsByType(_ context.Context, _ channel.ChannelType) ([]channel.ChannelConfig, error) {
if s.err != nil {
return nil, s.err
}
return s.configs, nil
}
type fakeWebhookManager struct {
calls []struct {
cfg channel.ChannelConfig
@@ -44,7 +32,7 @@ func (m *fakeWebhookManager) HandleInbound(_ context.Context, cfg channel.Channe
return m.err
}
func TestWebhookHandler_URLVerification(t *testing.T) {
func TestHandleWebhook_URLVerification(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -83,28 +71,14 @@ func TestWebhookHandler_URLVerification(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
store := &fakeWebhookStore{
configs: []channel.ChannelConfig{
{
ID: testWebhookConfigID,
BotID: "bot-1",
ChannelType: Type,
Credentials: tc.credentials,
},
},
}
cfg := newWebhookConfig(tc.credentials)
manager := &fakeWebhookManager{}
h := NewWebhookHandler(nil, store, manager)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(tc.body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("config_id")
c.SetParamValues(testWebhookConfigID)
adapter := NewFeishuAdapter(nil)
err := h.Handle(c)
err := adapter.HandleWebhook(context.Background(), cfg, manager.HandleInbound, req, rec)
if tc.wantError {
if err == nil {
t.Fatal("expected error")
@@ -138,41 +112,28 @@ func TestWebhookHandler_URLVerification(t *testing.T) {
}
}
func TestWebhookHandler_URLVerificationWithEncryptKeyWithoutVerificationToken(t *testing.T) {
func TestHandleWebhook_URLVerificationWithEncryptKeyWithoutVerificationToken(t *testing.T) {
t.Parallel()
store := &fakeWebhookStore{
configs: []channel.ChannelConfig{
{
ID: testWebhookConfigID,
BotID: "bot-1",
ChannelType: Type,
Credentials: map[string]any{
"app_id": "app",
"app_secret": "secret",
"encrypt_key": "encrypt-key",
"inbound_mode": "webhook",
},
},
},
}
cfg := newWebhookConfig(map[string]any{
"app_id": "app",
"app_secret": "secret",
"encrypt_key": "encrypt-key",
"inbound_mode": "webhook",
})
manager := &fakeWebhookManager{}
h := NewWebhookHandler(nil, store, manager)
encrypt, err := larkcore.EncryptedEventMsg(context.Background(), `{"challenge":"hello","token":"verify-token","type":"url_verification"}`, "encrypt-key")
if err != nil {
t.Fatalf("failed to encrypt challenge payload: %v", err)
}
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(`{"encrypt":"`+encrypt+`"}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("config_id")
c.SetParamValues(testWebhookConfigID)
adapter := NewFeishuAdapter(nil)
if err := h.Handle(c); err != nil {
if err := adapter.HandleWebhook(context.Background(), cfg, manager.HandleInbound, req, rec); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rec.Code != http.StatusOK {
@@ -186,19 +147,20 @@ func TestWebhookHandler_URLVerificationWithEncryptKeyWithoutVerificationToken(t
}
}
func TestWebhookHandler_Probe(t *testing.T) {
func TestHandleWebhook_Probe(t *testing.T) {
t.Parallel()
h := NewWebhookHandler(nil, &fakeWebhookStore{}, &fakeWebhookManager{})
e := echo.New()
cfg := newWebhookConfig(map[string]any{
"app_id": "app",
"app_secret": "secret",
"verification_token": "verify-token",
"inbound_mode": "webhook",
})
req := httptest.NewRequest(http.MethodGet, "/channels/feishu/webhook/"+testWebhookConfigID, nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("config_id")
c.SetParamValues(testWebhookConfigID)
adapter := NewFeishuAdapter(nil)
if err := h.HandleProbe(c); err != nil {
if err := adapter.HandleWebhook(context.Background(), cfg, (&fakeWebhookManager{}).HandleInbound, req, rec); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rec.Code != http.StatusOK {
@@ -209,67 +171,24 @@ func TestWebhookHandler_Probe(t *testing.T) {
}
}
func TestWebhookHandler_ConfigLookupRejectsNotFound(t *testing.T) {
func TestHandleWebhook_EventCallbackDispatchesInbound(t *testing.T) {
t.Parallel()
store := &fakeWebhookStore{}
h := NewWebhookHandler(nil, store, &fakeWebhookManager{})
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/not-found", strings.NewReader(`{}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("config_id")
c.SetParamValues("not-found")
err := h.Handle(c)
if err == nil {
t.Fatal("expected not found error")
}
he := &echo.HTTPError{}
if !errors.As(err, &he) {
t.Fatalf("expected HTTPError, got %T", err)
}
if he.Code != http.StatusNotFound {
t.Fatalf("unexpected status code: %d", he.Code)
}
}
func TestWebhookHandler_EventCallbackDispatchesInbound(t *testing.T) {
t.Parallel()
store := &fakeWebhookStore{
configs: []channel.ChannelConfig{
{
ID: testWebhookConfigID,
BotID: "bot-1",
ChannelType: Type,
SelfIdentity: map[string]any{
"open_id": "ou_bot_1",
},
Credentials: map[string]any{
"app_id": "app",
"app_secret": "secret",
"verification_token": "verify-token",
"inbound_mode": "webhook",
},
},
},
}
cfg := newWebhookConfig(map[string]any{
"app_id": "app",
"app_secret": "secret",
"verification_token": "verify-token",
"inbound_mode": "webhook",
})
cfg.SelfIdentity = map[string]any{"open_id": "ou_bot_1"}
manager := &fakeWebhookManager{}
h := NewWebhookHandler(nil, store, manager)
e := echo.New()
body := `{"schema":"2.0","header":{"event_id":"evt_1","event_type":"im.message.receive_v1","token":"verify-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_1","user_id":"u_user_1"}},"message":{"message_id":"om_1","chat_id":"oc_1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello\"}"}},"type":"event_callback"}`
req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("config_id")
c.SetParamValues(testWebhookConfigID)
adapter := NewFeishuAdapter(nil)
if err := h.Handle(c); err != nil {
if err := adapter.HandleWebhook(context.Background(), cfg, manager.HandleInbound, req, rec); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rec.Code != http.StatusOK {
@@ -287,38 +206,24 @@ func TestWebhookHandler_EventCallbackDispatchesInbound(t *testing.T) {
}
}
func TestWebhookHandler_EventCallbackUsesExternalIdentityForMentionFilter(t *testing.T) {
func TestHandleWebhook_EventCallbackUsesExternalIdentityForMentionFilter(t *testing.T) {
t.Parallel()
store := &fakeWebhookStore{
configs: []channel.ChannelConfig{
{
ID: testWebhookConfigID,
BotID: "bot-1",
ChannelType: Type,
ExternalIdentity: "open_id:ou_bot_1",
Credentials: map[string]any{
"app_id": "app",
"app_secret": "secret",
"verification_token": "verify-token",
"inbound_mode": "webhook",
},
},
},
}
cfg := newWebhookConfig(map[string]any{
"app_id": "app",
"app_secret": "secret",
"verification_token": "verify-token",
"inbound_mode": "webhook",
})
cfg.ExternalIdentity = "open_id:ou_bot_1"
manager := &fakeWebhookManager{}
h := NewWebhookHandler(nil, store, manager)
e := echo.New()
body := `{"schema":"2.0","header":{"event_id":"evt_2","event_type":"im.message.receive_v1","token":"verify-token"},"event":{"sender":{"sender_id":{"open_id":"ou_user_2","user_id":"u_user_2"}},"message":{"message_id":"om_2","chat_id":"oc_group_1","chat_type":"group","message_type":"text","content":"{\"text\":\"<at user_id=\\\"ou_other_user\\\"></at> hello\"}"}},"type":"event_callback"}`
req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("config_id")
c.SetParamValues(testWebhookConfigID)
adapter := NewFeishuAdapter(nil)
if err := h.Handle(c); err != nil {
if err := adapter.HandleWebhook(context.Background(), cfg, manager.HandleInbound, req, rec); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rec.Code != http.StatusOK {
@@ -333,7 +238,7 @@ func TestWebhookHandler_EventCallbackUsesExternalIdentityForMentionFilter(t *tes
}
}
func TestWebhookHandler_EventCallbackRejectsInvalidTokenWhenEncryptKeyMissing(t *testing.T) {
func TestHandleWebhook_EventCallbackRejectsInvalidTokenWhenEncryptKeyMissing(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -375,28 +280,14 @@ func TestWebhookHandler_EventCallbackRejectsInvalidTokenWhenEncryptKeyMissing(t
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
store := &fakeWebhookStore{
configs: []channel.ChannelConfig{
{
ID: testWebhookConfigID,
BotID: "bot-1",
ChannelType: Type,
Credentials: tc.credentials,
},
},
}
cfg := newWebhookConfig(tc.credentials)
manager := &fakeWebhookManager{}
h := NewWebhookHandler(nil, store, manager)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(tc.body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("config_id")
c.SetParamValues(testWebhookConfigID)
adapter := NewFeishuAdapter(nil)
err := h.Handle(c)
err := adapter.HandleWebhook(context.Background(), cfg, manager.HandleInbound, req, rec)
if err == nil {
t.Fatal("expected unauthorized error")
}
@@ -414,36 +305,22 @@ func TestWebhookHandler_EventCallbackRejectsInvalidTokenWhenEncryptKeyMissing(t
}
}
func TestWebhookHandler_RejectsOversizedBody(t *testing.T) {
func TestHandleWebhook_RejectsOversizedBody(t *testing.T) {
t.Parallel()
store := &fakeWebhookStore{
configs: []channel.ChannelConfig{
{
ID: testWebhookConfigID,
BotID: "bot-1",
ChannelType: Type,
Credentials: map[string]any{
"app_id": "app",
"app_secret": "secret",
"verification_token": "verify-token",
"inbound_mode": "webhook",
},
},
},
}
cfg := newWebhookConfig(map[string]any{
"app_id": "app",
"app_secret": "secret",
"verification_token": "verify-token",
"inbound_mode": "webhook",
})
manager := &fakeWebhookManager{}
h := NewWebhookHandler(nil, store, manager)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/channels/feishu/webhook/"+testWebhookConfigID, strings.NewReader(strings.Repeat("x", int(webhookMaxBodyBytes)+1)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("config_id")
c.SetParamValues(testWebhookConfigID)
adapter := NewFeishuAdapter(nil)
err := h.Handle(c)
err := adapter.HandleWebhook(context.Background(), cfg, manager.HandleInbound, req, rec)
if err == nil {
t.Fatal("expected payload-too-large error")
}
@@ -459,3 +336,12 @@ func TestWebhookHandler_RejectsOversizedBody(t *testing.T) {
t.Fatalf("expected no inbound calls, got %d", len(manager.calls))
}
}
func newWebhookConfig(credentials map[string]any) channel.ChannelConfig {
return channel.ChannelConfig{
ID: testWebhookConfigID,
BotID: "bot-1",
ChannelType: Type,
Credentials: credentials,
}
}