mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user