diff --git a/test/webhook/callbacks_test.go b/test/webhook/callbacks_test.go new file mode 100644 index 000000000..37193c0f1 --- /dev/null +++ b/test/webhook/callbacks_test.go @@ -0,0 +1,209 @@ +// Copyright © 2023 OpenIM. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + cbapi "github.com/openimsdk/open-im-server/v3/pkg/callbackstruct" + sdkws "github.com/openimsdk/protocol/sdkws" + "github.com/openimsdk/protocol/wrapperspb" + "github.com/stretchr/testify/require" +) + +func runWebhookTest(t *testing.T, srv *Server, command string, reqObj any) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(reqObj) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/callbackExample/"+command, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + return w +} + +func TestAllWebhookCallbacks_UserAndStatus(t *testing.T) { + t.Parallel() + srv := NewServer() + + t.Run("UserRegister", func(t *testing.T) { + w := runWebhookTest(t, srv, cbapi.CallbackBeforeUserRegisterCommand, &cbapi.CallbackBeforeUserRegisterReq{ + CallbackCommand: cbapi.CallbackCommand(cbapi.CallbackBeforeUserRegisterCommand), + Users: []*sdkws.UserInfo{{UserID: "u1", Nickname: "User1"}}, + }) + var resp cbapi.CallbackBeforeUserRegisterResp + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp.Users, 1) + require.Equal(t, "User1", resp.Users[0].Nickname) + + runWebhookTest(t, srv, cbapi.CallbackAfterUserRegisterCommand, &cbapi.CallbackAfterUserRegisterReq{ + CallbackCommand: cbapi.CallbackCommand(cbapi.CallbackAfterUserRegisterCommand), + Users: []*sdkws.UserInfo{{UserID: "u1"}}, + }) + }) + + t.Run("UpdateUserInfo", func(t *testing.T) { + newNick := "NewNick" + w := runWebhookTest(t, srv, cbapi.CallbackBeforeUpdateUserInfoCommand, &cbapi.CallbackBeforeUpdateUserInfoReq{ + CallbackCommand: cbapi.CallbackCommand(cbapi.CallbackBeforeUpdateUserInfoCommand), + UserID: "u1", + Nickname: &newNick, + }) + var resp cbapi.CallbackBeforeUpdateUserInfoResp + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.NotNil(t, resp.Nickname) + require.Equal(t, "NewNick", *resp.Nickname) + + runWebhookTest(t, srv, cbapi.CallbackAfterUpdateUserInfoCommand, &cbapi.CallbackAfterUpdateUserInfoReq{ + CallbackCommand: cbapi.CallbackCommand(cbapi.CallbackAfterUpdateUserInfoCommand), + UserID: "u1", + Nickname: "NewNick", + }) + }) + + t.Run("UpdateUserInfoEx", func(t *testing.T) { + w := runWebhookTest(t, srv, cbapi.CallbackBeforeUpdateUserInfoExCommand, &cbapi.CallbackBeforeUpdateUserInfoExReq{ + CallbackCommand: cbapi.CallbackCommand(cbapi.CallbackBeforeUpdateUserInfoExCommand), + UserID: "u1", + Ex: wrapperspb.String("new-ex"), + }) + var resp cbapi.CallbackBeforeUpdateUserInfoExResp + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.NotNil(t, resp.Ex) + require.Equal(t, "new-ex", resp.Ex.Value) + + runWebhookTest(t, srv, cbapi.CallbackAfterUpdateUserInfoExCommand, &cbapi.CallbackAfterUpdateUserInfoExReq{ + CallbackCommand: cbapi.CallbackCommand(cbapi.CallbackAfterUpdateUserInfoExCommand), + UserID: "u1", + Ex: wrapperspb.String("new-ex"), + }) + }) + + t.Run("UserStatusGateway", func(t *testing.T) { + for _, cmd := range []string{ + cbapi.CallbackAfterUserOnlineCommand, + cbapi.CallbackAfterUserOfflineCommand, + cbapi.CallbackAfterUserKickOffCommand, + } { + runWebhookTest(t, srv, cmd, &cbapi.CallbackUserOnlineReq{ + UserStatusCallbackReq: cbapi.UserStatusCallbackReq{ + UserStatusBaseCallback: cbapi.UserStatusBaseCallback{ + CallbackCommand: cmd, + Platform: "iOS", + }, + UserID: "u1", + }, + }) + } + }) +} + +func TestAllWebhookCallbacks_FriendAndGroup(t *testing.T) { + t.Parallel() + srv := NewServer() + + t.Run("FriendCallbacks", func(t *testing.T) { + friendCmds := []string{ + cbapi.CallbackBeforeAddFriendCommand, + cbapi.CallbackAfterAddFriendCommand, + cbapi.CallbackBeforeAddFriendAgreeCommand, + cbapi.CallbackAfterAddFriendAgreeCommand, + cbapi.CallbackAfterDeleteFriendCommand, + cbapi.CallbackBeforeImportFriendsCommand, + cbapi.CallbackAfterImportFriendsCommand, + cbapi.CallbackBeforeSetFriendRemarkCommand, + cbapi.CallbackAfterSetFriendRemarkCommand, + cbapi.CallbackBeforeAddBlackCommand, + cbapi.CallbackAfterRemoveBlackCommand, + } + for _, cmd := range friendCmds { + runWebhookTest(t, srv, cmd, &cbapi.CallbackBeforeAddFriendReq{ + CallbackCommand: cbapi.CallbackCommand(cmd), + FromUserID: "u1", + ToUserID: "u2", + }) + } + }) + + t.Run("GroupCallbacks", func(t *testing.T) { + groupCmds := []string{ + cbapi.CallbackBeforeCreateGroupCommand, + cbapi.CallbackAfterCreateGroupCommand, + cbapi.CallbackBeforeMembersJoinGroupCommand, + cbapi.CallbackBeforeJoinGroupCommand, + cbapi.CallbackAfterJoinGroupCommand, + cbapi.CallbackAfterQuitGroupCommand, + cbapi.CallbackAfterKickGroupCommand, + cbapi.CallbackAfterDisMissGroupCommand, + cbapi.CallbackAfterTransferGroupOwnerCommand, + cbapi.CallbackBeforeInviteJoinGroupCommand, + cbapi.CallbackBeforeSetGroupInfoCommand, + cbapi.CallbackAfterSetGroupInfoCommand, + cbapi.CallbackBeforeSetGroupInfoExCommand, + cbapi.CallbackAfterSetGroupInfoExCommand, + cbapi.CallbackBeforeSetGroupMemberInfoCommand, + cbapi.CallbackAfterSetGroupMemberInfoCommand, + } + for _, cmd := range groupCmds { + runWebhookTest(t, srv, cmd, &cbapi.CallbackQuitGroupReq{ + CallbackCommand: cbapi.CallbackCommand(cmd), + GroupID: "g1", + UserID: "u1", + }) + } + }) +} + +func TestAllWebhookCallbacks_MsgAndPush(t *testing.T) { + t.Parallel() + srv := NewServer() + + msgCmds := []string{ + cbapi.CallbackBeforeSendSingleMsgCommand, + cbapi.CallbackAfterSendSingleMsgCommand, + cbapi.CallbackBeforeSendGroupMsgCommand, + cbapi.CallbackAfterSendGroupMsgCommand, + cbapi.CallbackBeforeMsgModifyCommand, + cbapi.CallbackAfterSingleMsgReadCommand, + cbapi.CallbackAfterGroupMsgReadCommand, + cbapi.CallbackAfterMsgSaveDBCommand, + cbapi.CallbackAfterRevokeMsgCommand, + cbapi.CallbackBeforeOfflinePushCommand, + cbapi.CallbackBeforeOnlinePushCommand, + cbapi.CallbackBeforeGroupOnlinePushCommand, + } + + for _, cmd := range msgCmds { + cmd := cmd + t.Run(cmd, func(t *testing.T) { + t.Parallel() + runWebhookTest(t, srv, cmd, &cbapi.CallbackAfterMsgSaveDBReq{ + CommonCallbackReq: cbapi.CommonCallbackReq{ + SendID: "u1", + CallbackCommand: cmd, + }, + RecvID: "u2", + GroupID: "g1", + }) + }) + } +} diff --git a/test/webhook/cmd/main.go b/test/webhook/cmd/main.go new file mode 100644 index 000000000..d9ab61666 --- /dev/null +++ b/test/webhook/cmd/main.go @@ -0,0 +1,103 @@ +// Copyright © 2023 OpenIM. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/gin-gonic/gin" + cbapi "github.com/openimsdk/open-im-server/v3/pkg/callbackstruct" + "github.com/openimsdk/open-im-server/v3/test/webhook" + "github.com/openimsdk/protocol/constant" +) + +func main() { + port := flag.Int("port", 10006, "Webhook server listening port") + sensitiveWord := flag.String("mask-word", "xxx", "Keyword to mask in callbackBeforeMsgModifyCommand") + flag.Parse() + + gin.SetMode(gin.ReleaseMode) + srv := webhook.NewServer() + + // 1. Log every incoming webhook request in real-time + srv.OnRequest(func(command string, reqBody []byte, c *gin.Context) { + log.Printf("[Webhook] %s %s | Command: %s | Body: %s", + c.Request.Method, c.Request.URL.Path, command, string(reqBody)) + }) + + // 2. Example: Custom hook for message content modification (sensitive word filtering) + if *sensitiveWord != "" { + webhook.RegisterCallback(srv, cbapi.CallbackBeforeMsgModifyCommand, func(c *gin.Context, req *cbapi.CallbackMsgModifyCommandReq) (*cbapi.CallbackMsgModifyCommandResp, error) { + var resp cbapi.CallbackMsgModifyCommandResp + if req.ContentType != constant.Text { + return &resp, nil + } + var textElem struct { + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(req.Content), &textElem); err != nil { + return nil, err + } + if strings.Contains(textElem.Content, *sensitiveWord) { + textElem.Content = strings.ReplaceAll(textElem.Content, *sensitiveWord, strings.Repeat("*", len(*sensitiveWord))) + masked, err := json.Marshal(&textElem) + if err != nil { + return nil, err + } + s := string(masked) + resp.Content = &s + log.Printf("[Webhook Mask] Masked content: %s -> %s", req.Content, s) + } + return &resp, nil + }) + } + + addr := fmt.Sprintf(":%d", *port) + log.Printf("=================================================================") + log.Printf(" OpenIM Mock Webhook Server is listening on %s", addr) + log.Printf(" Callback URL in config/webhooks.yml:") + log.Printf(" url: 'http://127.0.0.1:%d/callbackExample'", *port) + log.Printf(" Supported callbacks: 48 registered OpenIM callback commands") + log.Printf(" Press Ctrl+C to stop") + log.Printf("=================================================================") + + go func() { + if err := srv.Start(addr); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server listen error: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down Webhook Server...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + log.Println("Webhook Server exited cleanly.") +} diff --git a/test/webhook/default_handlers.go b/test/webhook/default_handlers.go new file mode 100644 index 000000000..d7624ca0b --- /dev/null +++ b/test/webhook/default_handlers.go @@ -0,0 +1,177 @@ +// Copyright © 2023 OpenIM. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "github.com/gin-gonic/gin" + cbapi "github.com/openimsdk/open-im-server/v3/pkg/callbackstruct" +) + +// RegisterDefaultHandlers registers default success handlers for all supported OpenIM webhook commands. +func (s *Server) RegisterDefaultHandlers() { + // User + RegisterCallback[cbapi.CallbackBeforeUserRegisterReq, cbapi.CallbackBeforeUserRegisterResp](s, cbapi.CallbackBeforeUserRegisterCommand, func(c *gin.Context, req *cbapi.CallbackBeforeUserRegisterReq) (*cbapi.CallbackBeforeUserRegisterResp, error) { + return &cbapi.CallbackBeforeUserRegisterResp{Users: req.Users}, nil + }) + RegisterCallback[cbapi.CallbackAfterUserRegisterReq, cbapi.CallbackAfterUserRegisterResp](s, cbapi.CallbackAfterUserRegisterCommand, func(c *gin.Context, req *cbapi.CallbackAfterUserRegisterReq) (*cbapi.CallbackAfterUserRegisterResp, error) { + return &cbapi.CallbackAfterUserRegisterResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforeUpdateUserInfoReq, cbapi.CallbackBeforeUpdateUserInfoResp](s, cbapi.CallbackBeforeUpdateUserInfoCommand, func(c *gin.Context, req *cbapi.CallbackBeforeUpdateUserInfoReq) (*cbapi.CallbackBeforeUpdateUserInfoResp, error) { + return &cbapi.CallbackBeforeUpdateUserInfoResp{Nickname: req.Nickname, FaceURL: req.FaceURL, Ex: req.Ex}, nil + }) + RegisterCallback[cbapi.CallbackAfterUpdateUserInfoReq, cbapi.CallbackAfterUpdateUserInfoResp](s, cbapi.CallbackAfterUpdateUserInfoCommand, func(c *gin.Context, req *cbapi.CallbackAfterUpdateUserInfoReq) (*cbapi.CallbackAfterUpdateUserInfoResp, error) { + return &cbapi.CallbackAfterUpdateUserInfoResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforeUpdateUserInfoExReq, cbapi.CallbackBeforeUpdateUserInfoExResp](s, cbapi.CallbackBeforeUpdateUserInfoExCommand, func(c *gin.Context, req *cbapi.CallbackBeforeUpdateUserInfoExReq) (*cbapi.CallbackBeforeUpdateUserInfoExResp, error) { + return &cbapi.CallbackBeforeUpdateUserInfoExResp{Nickname: req.Nickname, FaceURL: req.FaceURL, Ex: req.Ex}, nil + }) + RegisterCallback[cbapi.CallbackAfterUpdateUserInfoExReq, cbapi.CallbackAfterUpdateUserInfoExResp](s, cbapi.CallbackAfterUpdateUserInfoExCommand, func(c *gin.Context, req *cbapi.CallbackAfterUpdateUserInfoExReq) (*cbapi.CallbackAfterUpdateUserInfoExResp, error) { + return &cbapi.CallbackAfterUpdateUserInfoExResp{}, nil + }) + + // User Status Gateway + RegisterCallback[cbapi.CallbackUserOnlineReq, cbapi.CallbackUserOnlineResp](s, cbapi.CallbackAfterUserOnlineCommand, func(c *gin.Context, req *cbapi.CallbackUserOnlineReq) (*cbapi.CallbackUserOnlineResp, error) { + return &cbapi.CallbackUserOnlineResp{}, nil + }) + RegisterCallback[cbapi.CallbackUserOfflineReq, cbapi.CallbackUserOfflineResp](s, cbapi.CallbackAfterUserOfflineCommand, func(c *gin.Context, req *cbapi.CallbackUserOfflineReq) (*cbapi.CallbackUserOfflineResp, error) { + return &cbapi.CallbackUserOfflineResp{}, nil + }) + RegisterCallback[cbapi.CallbackUserKickOffReq, cbapi.CallbackUserKickOffResp](s, cbapi.CallbackAfterUserKickOffCommand, func(c *gin.Context, req *cbapi.CallbackUserKickOffReq) (*cbapi.CallbackUserKickOffResp, error) { + return &cbapi.CallbackUserKickOffResp{}, nil + }) + + // Friend + RegisterCallback[cbapi.CallbackBeforeAddFriendReq, cbapi.CallbackBeforeAddFriendResp](s, cbapi.CallbackBeforeAddFriendCommand, func(c *gin.Context, req *cbapi.CallbackBeforeAddFriendReq) (*cbapi.CallbackBeforeAddFriendResp, error) { + return &cbapi.CallbackBeforeAddFriendResp{}, nil + }) + RegisterCallback[cbapi.CallbackAfterAddFriendReq, cbapi.CallbackAfterAddFriendResp](s, cbapi.CallbackAfterAddFriendCommand, func(c *gin.Context, req *cbapi.CallbackAfterAddFriendReq) (*cbapi.CallbackAfterAddFriendResp, error) { + return &cbapi.CallbackAfterAddFriendResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforeAddFriendAgreeReq, cbapi.CallbackBeforeAddFriendAgreeResp](s, cbapi.CallbackBeforeAddFriendAgreeCommand, func(c *gin.Context, req *cbapi.CallbackBeforeAddFriendAgreeReq) (*cbapi.CallbackBeforeAddFriendAgreeResp, error) { + return &cbapi.CallbackBeforeAddFriendAgreeResp{}, nil + }) + RegisterCallback[cbapi.CallbackAfterAddFriendAgreeReq, cbapi.CallbackAfterAddFriendAgreeResp](s, cbapi.CallbackAfterAddFriendAgreeCommand, func(c *gin.Context, req *cbapi.CallbackAfterAddFriendAgreeReq) (*cbapi.CallbackAfterAddFriendAgreeResp, error) { + return &cbapi.CallbackAfterAddFriendAgreeResp{}, nil + }) + RegisterCallback[cbapi.CallbackAfterDeleteFriendReq, cbapi.CallbackAfterDeleteFriendResp](s, cbapi.CallbackAfterDeleteFriendCommand, func(c *gin.Context, req *cbapi.CallbackAfterDeleteFriendReq) (*cbapi.CallbackAfterDeleteFriendResp, error) { + return &cbapi.CallbackAfterDeleteFriendResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforeImportFriendsReq, cbapi.CallbackBeforeImportFriendsResp](s, cbapi.CallbackBeforeImportFriendsCommand, func(c *gin.Context, req *cbapi.CallbackBeforeImportFriendsReq) (*cbapi.CallbackBeforeImportFriendsResp, error) { + return &cbapi.CallbackBeforeImportFriendsResp{FriendUserIDs: req.FriendUserIDs}, nil + }) + RegisterCallback[cbapi.CallbackAfterImportFriendsReq, cbapi.CallbackAfterImportFriendsResp](s, cbapi.CallbackAfterImportFriendsCommand, func(c *gin.Context, req *cbapi.CallbackAfterImportFriendsReq) (*cbapi.CallbackAfterImportFriendsResp, error) { + return &cbapi.CallbackAfterImportFriendsResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforeSetFriendRemarkReq, cbapi.CallbackBeforeSetFriendRemarkResp](s, cbapi.CallbackBeforeSetFriendRemarkCommand, func(c *gin.Context, req *cbapi.CallbackBeforeSetFriendRemarkReq) (*cbapi.CallbackBeforeSetFriendRemarkResp, error) { + return &cbapi.CallbackBeforeSetFriendRemarkResp{Remark: req.Remark}, nil + }) + RegisterCallback[cbapi.CallbackAfterSetFriendRemarkReq, cbapi.CallbackAfterSetFriendRemarkResp](s, cbapi.CallbackAfterSetFriendRemarkCommand, func(c *gin.Context, req *cbapi.CallbackAfterSetFriendRemarkReq) (*cbapi.CallbackAfterSetFriendRemarkResp, error) { + return &cbapi.CallbackAfterSetFriendRemarkResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforeAddBlackReq, cbapi.CallbackBeforeAddBlackResp](s, cbapi.CallbackBeforeAddBlackCommand, func(c *gin.Context, req *cbapi.CallbackBeforeAddBlackReq) (*cbapi.CallbackBeforeAddBlackResp, error) { + return &cbapi.CallbackBeforeAddBlackResp{}, nil + }) + RegisterCallback[cbapi.CallbackAfterRemoveBlackReq, cbapi.CallbackAfterRemoveBlackResp](s, cbapi.CallbackAfterRemoveBlackCommand, func(c *gin.Context, req *cbapi.CallbackAfterRemoveBlackReq) (*cbapi.CallbackAfterRemoveBlackResp, error) { + return &cbapi.CallbackAfterRemoveBlackResp{}, nil + }) + + // Group + RegisterCallback[cbapi.CallbackBeforeCreateGroupReq, cbapi.CallbackBeforeCreateGroupResp](s, cbapi.CallbackBeforeCreateGroupCommand, func(c *gin.Context, req *cbapi.CallbackBeforeCreateGroupReq) (*cbapi.CallbackBeforeCreateGroupResp, error) { + return &cbapi.CallbackBeforeCreateGroupResp{}, nil + }) + RegisterCallback[cbapi.CallbackAfterCreateGroupReq, cbapi.CallbackAfterCreateGroupResp](s, cbapi.CallbackAfterCreateGroupCommand, func(c *gin.Context, req *cbapi.CallbackAfterCreateGroupReq) (*cbapi.CallbackAfterCreateGroupResp, error) { + return &cbapi.CallbackAfterCreateGroupResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforeMembersJoinGroupReq, cbapi.CallbackBeforeMembersJoinGroupResp](s, cbapi.CallbackBeforeMembersJoinGroupCommand, func(c *gin.Context, req *cbapi.CallbackBeforeMembersJoinGroupReq) (*cbapi.CallbackBeforeMembersJoinGroupResp, error) { + return &cbapi.CallbackBeforeMembersJoinGroupResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforeMembersJoinGroupReq, cbapi.CallbackBeforeMembersJoinGroupResp](s, cbapi.CallbackBeforeJoinGroupCommand, func(c *gin.Context, req *cbapi.CallbackBeforeMembersJoinGroupReq) (*cbapi.CallbackBeforeMembersJoinGroupResp, error) { + return &cbapi.CallbackBeforeMembersJoinGroupResp{}, nil + }) + RegisterCallback[cbapi.CallbackAfterJoinGroupReq, cbapi.CallbackAfterJoinGroupResp](s, cbapi.CallbackAfterJoinGroupCommand, func(c *gin.Context, req *cbapi.CallbackAfterJoinGroupReq) (*cbapi.CallbackAfterJoinGroupResp, error) { + return &cbapi.CallbackAfterJoinGroupResp{}, nil + }) + RegisterCallback[cbapi.CallbackQuitGroupReq, cbapi.CallbackQuitGroupResp](s, cbapi.CallbackAfterQuitGroupCommand, func(c *gin.Context, req *cbapi.CallbackQuitGroupReq) (*cbapi.CallbackQuitGroupResp, error) { + return &cbapi.CallbackQuitGroupResp{}, nil + }) + RegisterCallback[cbapi.CallbackKillGroupMemberReq, cbapi.CallbackKillGroupMemberResp](s, cbapi.CallbackAfterKickGroupCommand, func(c *gin.Context, req *cbapi.CallbackKillGroupMemberReq) (*cbapi.CallbackKillGroupMemberResp, error) { + return &cbapi.CallbackKillGroupMemberResp{}, nil + }) + RegisterCallback[cbapi.CallbackDisMissGroupReq, cbapi.CallbackDisMissGroupResp](s, cbapi.CallbackAfterDisMissGroupCommand, func(c *gin.Context, req *cbapi.CallbackDisMissGroupReq) (*cbapi.CallbackDisMissGroupResp, error) { + return &cbapi.CallbackDisMissGroupResp{}, nil + }) + RegisterCallback[cbapi.CallbackTransferGroupOwnerReq, cbapi.CallbackTransferGroupOwnerResp](s, cbapi.CallbackAfterTransferGroupOwnerCommand, func(c *gin.Context, req *cbapi.CallbackTransferGroupOwnerReq) (*cbapi.CallbackTransferGroupOwnerResp, error) { + return &cbapi.CallbackTransferGroupOwnerResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforeInviteUserToGroupReq, cbapi.CallbackBeforeInviteUserToGroupResp](s, cbapi.CallbackBeforeInviteJoinGroupCommand, func(c *gin.Context, req *cbapi.CallbackBeforeInviteUserToGroupReq) (*cbapi.CallbackBeforeInviteUserToGroupResp, error) { + return &cbapi.CallbackBeforeInviteUserToGroupResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforeSetGroupInfoReq, cbapi.CallbackBeforeSetGroupInfoResp](s, cbapi.CallbackBeforeSetGroupInfoCommand, func(c *gin.Context, req *cbapi.CallbackBeforeSetGroupInfoReq) (*cbapi.CallbackBeforeSetGroupInfoResp, error) { + return &cbapi.CallbackBeforeSetGroupInfoResp{GroupID: req.GroupID, GroupName: req.GroupName, Notification: req.Notification, Introduction: req.Introduction, FaceURL: req.FaceURL}, nil + }) + RegisterCallback[cbapi.CallbackAfterSetGroupInfoReq, cbapi.CallbackAfterSetGroupInfoResp](s, cbapi.CallbackAfterSetGroupInfoCommand, func(c *gin.Context, req *cbapi.CallbackAfterSetGroupInfoReq) (*cbapi.CallbackAfterSetGroupInfoResp, error) { + return &cbapi.CallbackAfterSetGroupInfoResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforeSetGroupInfoExReq, cbapi.CallbackBeforeSetGroupInfoExResp](s, cbapi.CallbackBeforeSetGroupInfoExCommand, func(c *gin.Context, req *cbapi.CallbackBeforeSetGroupInfoExReq) (*cbapi.CallbackBeforeSetGroupInfoExResp, error) { + return &cbapi.CallbackBeforeSetGroupInfoExResp{GroupID: req.GroupID, GroupName: req.GroupName, Notification: req.Notification, Introduction: req.Introduction, FaceURL: req.FaceURL, Ex: req.Ex, NeedVerification: req.NeedVerification, LookMemberInfo: req.LookMemberInfo, ApplyMemberFriend: req.ApplyMemberFriend}, nil + }) + RegisterCallback[cbapi.CallbackAfterSetGroupInfoExReq, cbapi.CallbackAfterSetGroupInfoExResp](s, cbapi.CallbackAfterSetGroupInfoExCommand, func(c *gin.Context, req *cbapi.CallbackAfterSetGroupInfoExReq) (*cbapi.CallbackAfterSetGroupInfoExResp, error) { + return &cbapi.CallbackAfterSetGroupInfoExResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforeSetGroupMemberInfoReq, cbapi.CallbackBeforeSetGroupMemberInfoResp](s, cbapi.CallbackBeforeSetGroupMemberInfoCommand, func(c *gin.Context, req *cbapi.CallbackBeforeSetGroupMemberInfoReq) (*cbapi.CallbackBeforeSetGroupMemberInfoResp, error) { + return &cbapi.CallbackBeforeSetGroupMemberInfoResp{Nickname: req.Nickname, FaceURL: req.FaceURL, RoleLevel: req.RoleLevel, Ex: req.Ex}, nil + }) + RegisterCallback[cbapi.CallbackAfterSetGroupMemberInfoReq, cbapi.CallbackAfterSetGroupMemberInfoResp](s, cbapi.CallbackAfterSetGroupMemberInfoCommand, func(c *gin.Context, req *cbapi.CallbackAfterSetGroupMemberInfoReq) (*cbapi.CallbackAfterSetGroupMemberInfoResp, error) { + return &cbapi.CallbackAfterSetGroupMemberInfoResp{}, nil + }) + + // Message & Push & Revoke + RegisterCallback[cbapi.CallbackBeforeSendSingleMsgReq, cbapi.CallbackBeforeSendSingleMsgResp](s, cbapi.CallbackBeforeSendSingleMsgCommand, func(c *gin.Context, req *cbapi.CallbackBeforeSendSingleMsgReq) (*cbapi.CallbackBeforeSendSingleMsgResp, error) { + return &cbapi.CallbackBeforeSendSingleMsgResp{}, nil + }) + RegisterCallback[cbapi.CallbackAfterSendSingleMsgReq, cbapi.CallbackAfterSendSingleMsgResp](s, cbapi.CallbackAfterSendSingleMsgCommand, func(c *gin.Context, req *cbapi.CallbackAfterSendSingleMsgReq) (*cbapi.CallbackAfterSendSingleMsgResp, error) { + return &cbapi.CallbackAfterSendSingleMsgResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforeSendGroupMsgReq, cbapi.CallbackBeforeSendGroupMsgResp](s, cbapi.CallbackBeforeSendGroupMsgCommand, func(c *gin.Context, req *cbapi.CallbackBeforeSendGroupMsgReq) (*cbapi.CallbackBeforeSendGroupMsgResp, error) { + return &cbapi.CallbackBeforeSendGroupMsgResp{}, nil + }) + RegisterCallback[cbapi.CallbackAfterSendGroupMsgReq, cbapi.CallbackAfterSendGroupMsgResp](s, cbapi.CallbackAfterSendGroupMsgCommand, func(c *gin.Context, req *cbapi.CallbackAfterSendGroupMsgReq) (*cbapi.CallbackAfterSendGroupMsgResp, error) { + return &cbapi.CallbackAfterSendGroupMsgResp{}, nil + }) + RegisterCallback[cbapi.CallbackMsgModifyCommandReq, cbapi.CallbackMsgModifyCommandResp](s, cbapi.CallbackBeforeMsgModifyCommand, func(c *gin.Context, req *cbapi.CallbackMsgModifyCommandReq) (*cbapi.CallbackMsgModifyCommandResp, error) { + return &cbapi.CallbackMsgModifyCommandResp{}, nil + }) + RegisterCallback[cbapi.CallbackSingleMsgReadReq, cbapi.CallbackSingleMsgReadResp](s, cbapi.CallbackAfterSingleMsgReadCommand, func(c *gin.Context, req *cbapi.CallbackSingleMsgReadReq) (*cbapi.CallbackSingleMsgReadResp, error) { + return &cbapi.CallbackSingleMsgReadResp{}, nil + }) + RegisterCallback[cbapi.CallbackGroupMsgReadReq, cbapi.CallbackGroupMsgReadResp](s, cbapi.CallbackAfterGroupMsgReadCommand, func(c *gin.Context, req *cbapi.CallbackGroupMsgReadReq) (*cbapi.CallbackGroupMsgReadResp, error) { + return &cbapi.CallbackGroupMsgReadResp{}, nil + }) + RegisterCallback[cbapi.CallbackAfterMsgSaveDBReq, cbapi.CallbackAfterMsgSaveDBResp](s, cbapi.CallbackAfterMsgSaveDBCommand, func(c *gin.Context, req *cbapi.CallbackAfterMsgSaveDBReq) (*cbapi.CallbackAfterMsgSaveDBResp, error) { + return &cbapi.CallbackAfterMsgSaveDBResp{}, nil + }) + RegisterCallback[cbapi.CallbackAfterRevokeMsgReq, cbapi.CallbackAfterRevokeMsgResp](s, cbapi.CallbackAfterRevokeMsgCommand, func(c *gin.Context, req *cbapi.CallbackAfterRevokeMsgReq) (*cbapi.CallbackAfterRevokeMsgResp, error) { + return &cbapi.CallbackAfterRevokeMsgResp{}, nil + }) + RegisterCallback[cbapi.CallbackBeforePushReq, cbapi.CallbackBeforePushResp](s, cbapi.CallbackBeforeOfflinePushCommand, func(c *gin.Context, req *cbapi.CallbackBeforePushReq) (*cbapi.CallbackBeforePushResp, error) { + return &cbapi.CallbackBeforePushResp{UserIDs: req.UserIDList, OfflinePushInfo: req.OfflinePushInfo}, nil + }) + RegisterCallback[cbapi.CallbackBeforePushReq, cbapi.CallbackBeforePushResp](s, cbapi.CallbackBeforeOnlinePushCommand, func(c *gin.Context, req *cbapi.CallbackBeforePushReq) (*cbapi.CallbackBeforePushResp, error) { + return &cbapi.CallbackBeforePushResp{UserIDs: req.UserIDList}, nil + }) + RegisterCallback[cbapi.CallbackBeforeSuperGroupOnlinePushReq, cbapi.CallbackBeforeSuperGroupOnlinePushResp](s, cbapi.CallbackBeforeGroupOnlinePushCommand, func(c *gin.Context, req *cbapi.CallbackBeforeSuperGroupOnlinePushReq) (*cbapi.CallbackBeforeSuperGroupOnlinePushResp, error) { + return &cbapi.CallbackBeforeSuperGroupOnlinePushResp{}, nil + }) +} diff --git a/test/webhook/server.go b/test/webhook/server.go new file mode 100644 index 000000000..973254973 --- /dev/null +++ b/test/webhook/server.go @@ -0,0 +1,184 @@ +// Copyright © 2023 OpenIM. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + cbapi "github.com/openimsdk/open-im-server/v3/pkg/callbackstruct" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// RequestRecord stores information about a received webhook request. +type RequestRecord struct { + Timestamp time.Time + Command string + Body []byte + Headers http.Header +} + +// Server is a mock webhook HTTP server for OpenIM callbacks. +type Server struct { + Engine *gin.Engine + handlers map[string]gin.HandlerFunc + mu sync.RWMutex + httpServer *http.Server + recordHistory bool + history []RequestRecord + requestHook func(command string, reqBody []byte, c *gin.Context) +} + +// NewServer creates a new mock webhook server instance. +func NewServer() *Server { + engine := gin.New() + engine.Use(gin.Recovery()) + + s := &Server{ + Engine: engine, + handlers: make(map[string]gin.HandlerFunc), + } + + engine.POST("/callbackExample/:command", func(c *gin.Context) { + s.dispatch(c, c.Param("command")) + }) + engine.POST("/:command", func(c *gin.Context) { + s.dispatch(c, c.Param("command")) + }) + + s.RegisterDefaultHandlers() + return s +} + +// SetRecordHistory enables or disables recording incoming request history. +func (s *Server) SetRecordHistory(enabled bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.recordHistory = enabled +} + +// OnRequest sets a callback function invoked whenever any webhook request arrives. +func (s *Server) OnRequest(hook func(command string, reqBody []byte, c *gin.Context)) { + s.mu.Lock() + defer s.mu.Unlock() + s.requestHook = hook +} + +// GetHistory returns a copy of all recorded requests. +func (s *Server) GetHistory() []RequestRecord { + s.mu.RLock() + defer s.mu.RUnlock() + result := make([]RequestRecord, len(s.history)) + copy(result, s.history) + return result +} + +// ClearHistory clears all recorded request history. +func (s *Server) ClearHistory() { + s.mu.Lock() + defer s.mu.Unlock() + s.history = nil +} + +// Start runs the webhook HTTP server on the given address (e.g. ":10006"). +func (s *Server) Start(addr string) error { + s.httpServer = &http.Server{ + Addr: addr, + Handler: s.Engine, + } + return s.httpServer.ListenAndServe() +} + +// Shutdown gracefully stops the running HTTP server. +func (s *Server) Shutdown(ctx context.Context) error { + if s.httpServer == nil { + return nil + } + return s.httpServer.Shutdown(ctx) +} + +func (s *Server) dispatch(c *gin.Context, command string) { + s.mu.RLock() + h, ok := s.handlers[command] + s.mu.RUnlock() + if ok { + h(c) + return + } + c.JSON(http.StatusOK, cbapi.CommonCallbackResp{}) +} + +// ServeHTTP conforms to the http.Handler interface. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.Engine.ServeHTTP(w, r) +} + +// RegisterCallback registers a typed handler for a callback command. +func RegisterCallback[Req any, Resp any](s *Server, command string, fn func(c *gin.Context, req *Req) (*Resp, error)) { + handler := func(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.String(http.StatusInternalServerError, err.Error()) + return + } + + s.mu.Lock() + if s.recordHistory { + s.history = append(s.history, RequestRecord{ + Timestamp: time.Now(), + Command: command, + Body: body, + Headers: c.Request.Header.Clone(), + }) + } + hook := s.requestHook + s.mu.Unlock() + + if hook != nil { + hook(command, body, c) + } + + var req Req + if len(body) > 0 { + if err := json.Unmarshal(body, &req); err != nil { + c.String(http.StatusBadRequest, fmt.Sprintf("unmarshal error: %v", err)) + return + } + } + resp, err := fn(c, &req) + if err != nil { + c.String(http.StatusInternalServerError, err.Error()) + return + } + if resp != nil { + c.JSON(http.StatusOK, resp) + } else { + c.JSON(http.StatusOK, cbapi.CommonCallbackResp{}) + } + } + + s.mu.Lock() + s.handlers[command] = handler + s.mu.Unlock() +} diff --git a/test/webhook/server_test.go b/test/webhook/server_test.go new file mode 100644 index 000000000..5235a91a9 --- /dev/null +++ b/test/webhook/server_test.go @@ -0,0 +1,122 @@ +// Copyright © 2023 OpenIM. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + cbapi "github.com/openimsdk/open-im-server/v3/pkg/callbackstruct" + sdkws "github.com/openimsdk/protocol/sdkws" + "github.com/stretchr/testify/require" +) + +func TestWebhookServer_DefaultEndpoints(t *testing.T) { + t.Parallel() + srv := NewServer() + + tests := []struct { + name string + url string + payload any + expectRes int + }{ + { + name: "CallbackBeforeUserRegister with prefix", + url: "/callbackExample/" + cbapi.CallbackBeforeUserRegisterCommand, + payload: &cbapi.CallbackBeforeUserRegisterReq{ + CallbackCommand: cbapi.CallbackCommand(cbapi.CallbackBeforeUserRegisterCommand), + Users: []*sdkws.UserInfo{{UserID: "u100", Nickname: "Alice"}}, + }, + expectRes: http.StatusOK, + }, + { + name: "CallbackAfterMsgSaveDB without prefix", + url: "/" + cbapi.CallbackAfterMsgSaveDBCommand, + payload: &cbapi.CallbackAfterMsgSaveDBReq{ + CommonCallbackReq: cbapi.CommonCallbackReq{ + SendID: "u100", + CallbackCommand: cbapi.CallbackAfterMsgSaveDBCommand, + }, + RecvID: "u200", + }, + expectRes: http.StatusOK, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body, err := json.Marshal(tt.payload) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, tt.url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + require.Equal(t, tt.expectRes, w.Code) + }) + } +} + +func TestWebhookServer_CustomHandlerOverride(t *testing.T) { + t.Parallel() + srv := NewServer() + + // Override BeforeMsgModify to mutate content + filteredContent := `{"text":"***"}` + RegisterCallback( + srv, + cbapi.CallbackBeforeMsgModifyCommand, + func(c *gin.Context, req *cbapi.CallbackMsgModifyCommandReq) (*cbapi.CallbackMsgModifyCommandResp, error) { + return &cbapi.CallbackMsgModifyCommandResp{ + CommonCallbackResp: cbapi.CommonCallbackResp{ + ErrCode: 0, + ErrMsg: "filtered", + }, + Content: &filteredContent, + }, nil + }, + ) + + reqPayload := cbapi.CallbackMsgModifyCommandReq{ + CommonCallbackReq: cbapi.CommonCallbackReq{ + SendID: "u1", + CallbackCommand: cbapi.CallbackBeforeMsgModifyCommand, + }, + } + body, err := json.Marshal(reqPayload) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/"+cbapi.CallbackBeforeMsgModifyCommand, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp cbapi.CallbackMsgModifyCommandResp + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.NotNil(t, resp.Content) + require.Equal(t, `{"text":"***"}`, *resp.Content) + require.Equal(t, "filtered", resp.ErrMsg) +}