feat(test): add webhook callback test server and handlers

This commit is contained in:
dsx137 2026-08-21 19:35:57 +08:00
parent 339db9092d
commit fe1a475451
5 changed files with 795 additions and 0 deletions

View File

@ -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",
})
})
}
}

103
test/webhook/cmd/main.go Normal file
View File

@ -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.")
}

View File

@ -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
})
}

184
test/webhook/server.go Normal file
View File

@ -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()
}

122
test/webhook/server_test.go Normal file
View File

@ -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)
}