dsx137 49ec272f88
feat: afterMsgSaveDB webhook (#3788)
* feat: afterMsgSaveDB webhook

fix(config): add default webhook event filters

fix(core): handle group mentions and webhook enqueue errors

Use group IDs when filtering group-message attention callbacks, and report failures when asynchronous webhook tasks cannot be queued or posted.

feat(webhook): add standalone mock webhook server CLI

test(webhook): add mock server and test suite for all callback commands

feat(msgtransfer): trigger afterMsgSaveDB webhook when message saved to DB

feat(config): add afterMsgSaveDB webhook configuration

feat(callbackstruct): add CallbackAfterMsgSaveDB command and request structs

* refactor(core): simplify asynchronous webhook enqueueing
2026-08-21 19:36:11 +08:00

185 lines
4.4 KiB
Go

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