Receiving callbacks
The IM service sends three kinds of requests to the callback endpoints you configure: event callbacks (including message copies), sync callbacks, and URL verification; see Event callback overview. The callback package handles these requests: it verifies signatures, prevents replay, de-duplicates by event ID, dispatches events and sync callbacks to your handlers, and returns the status codes the server expects. You only write the handlers.
| You want to | See |
|---|---|
| Create the handler, mount it on Gin or other frameworks or a cloud function | Create the handler, Mount it on a web framework |
| Handle events, de-duplicate, hand them to your own queue | Handle event callbacks |
| Review, reject or modify messages before they're sent | Handle sync callbacks |
| Deploy several instances with Redis for nonces and de-duplication | Nonce and dedup storage |
| Unit test your callback handlers | Local testing |
To get the full example running first, see the demo's callback setup steps: create the endpoint, start the demo with its secret, then verify the endpoint and subscribe to events.
import "github.com/DeepRespond-AI/deeprespond-im-server-go-sdk/callback"Create the handler
package main
import (
"context"
"encoding/json"
"log"
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/DeepRespond-AI/deeprespond-im-server-go-sdk/callback"
)
func main() {
fallback := callback.Allow() // 同步回调的处理函数出错或超时时放行
h, err := callback.NewHandler(callback.Config{
// 回调地址 ID → 密钥(控制台新建地址时显示;轮换期间新旧两个都配置,新的在前)
Secrets: callback.StaticSecrets{
os.Getenv("IM_CALLBACK_ENDPOINT_ID"): strings.Split(os.Getenv("IM_CALLBACK_SECRETS"), ","),
},
Nonces: callback.NewMemoryNonceStore(), // 多个实例部署时换成共享存储
Dedup: callback.NewMemoryDedupStore(24 * time.Hour), // 同上
Events: callback.EventHandlers{
MessageSent: func(ctx context.Context, ev *callback.Event, d *callback.MessageSent) error {
if d.Truncated || !d.ContentAvailable {
return nil // 内容被截短或已不可用:需要时按 MessageID 调用 OpenAPI 读取
}
// d.Body 与请求体中的逐字节相同,可以直接存档
log.Printf("消息 %s(会话 %s 序号 %d):%s", d.MessageID, d.ConversationID, d.Seq, d.Body)
return nil
},
UserCreated: func(ctx context.Context, ev *callback.Event, d *callback.UserCreated) error {
log.Printf("新用户 %s(%s)", d.Username, d.CreatedVia)
return nil
},
},
Hooks: callback.HookHandlers{
MessageBeforeSend: func(ctx context.Context, req *callback.MessageBeforeSend) (callback.Verdict, error) {
var body struct {
Text string `json:"text"`
}
_ = json.Unmarshal(req.Body, &body)
if strings.Contains(body.Text, "加微信") {
return callback.Reject("forbidden_word", "消息中有不允许的内容"), nil
}
return callback.Allow(), nil
},
},
HookFallback: &fallback,
Logger: slog.Default(),
})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("POST /im/callback", h)
log.Fatal(http.ListenAndServe(":8090", mux))
}callback.Handler implements http.Handler and does this for each request:
- Reads the raw request body (up to
MaxBodyBytes, 2 MB by default); the signature is computed over the raw bytes. - Finds the endpoint's secrets by the
X-IM-Endpoint-Idheader, verifies the signature and timestamp (within 5 minutes of now), then checks thatendpoint_idin the body matches the header; see Signatures and security. - Once the signature passes, records the nonce
X-IM-Nonceand rejects repeats within 10 minutes (preventing replay). - Dispatches by
kindin the body: URL verification is answered directly, sync callbacks go toHooks, and event callbacks are de-duplicated event by event and passed toEvents(orQueue).
Config options
| Field | Type | Description |
|---|---|---|
Secrets | callback.SecretProvider | Required. Returns an endpoint's list of secrets by endpoint ID; see Secrets |
Nonces | callback.NonceStore | Required. Nonce storage; see Nonce and dedup storage |
Dedup | callback.DedupStore | Required. Storage for de-duplicating event IDs |
Events | callback.EventHandlers | Event handlers (synchronous); see Handle event callbacks |
Queue | callback.EventQueue | Hands events to your durable queue (asynchronous); use instead of Events. See Asynchronous handling |
Hooks | callback.HookHandlers | Sync callback handlers; see Handle sync callbacks |
HookFallback | *callback.Verdict | What to return when a sync callback handler times out, errors or returns an invalid result; nil means return 503 and let the server apply the app's failure policy |
ClaimTTL | time.Duration | How long handling one event holds it, 30 seconds by default; after that the same event can be handled again in another request |
MaxBodyBytes | int64 | Request body limit, 2 MB by default; larger bodies get 413 |
MaxConcurrentHooks | int | Maximum sync callback handlers running at once, 100 by default; when full, requests return HookFallback (or 503) straight away |
Clock | func() time.Time | Current time, for tests; time.Now by default |
Logger | *slog.Logger | Logging, slog.Default() by default. Signature failures and invalid sync callback results are logged as errors; alert on them |
Secrets
Each callback endpoint has its own secret (starting with cbs_), shown when you create the endpoint in the console. callback.StaticSecrets is a fixed "endpoint ID → secrets" map; if secrets live in a config center and can change at any time, use callback.SecretFunc or implement SecretProvider yourself:
// 作为 callback.Config 的 Secrets
func secretsFromConfigCenter() callback.SecretProvider {
return callback.SecretFunc(func(ctx context.Context, endpointID string) ([]string, error) {
return loadCallbackSecrets(ctx, endpointID) // 从配置中心读取;找不到时返回空列表
})
}- Rotating secrets. After rotation in the console, the server signs with both the old and new secrets during a grace period. Add the new secret to the endpoint's list first (newest first), and remove the old one when the grace period ends; see Rotating secrets.
- Requests for an unknown endpoint get
401; ifSecretProviderreturns an error, the request gets503and the server resends later. - Endpoint IDs are unique across the platform, so when several apps share one receiver, one handler is enough: configure every app's endpoints, and tell apps apart in handlers with
callback.RequestFrom(ctx).App(the AppKey).
Mount it on a web framework
Handler is a standard http.Handler, so mount it on net/http's ServeMux, chi and other routers directly. The signature is computed over the raw body, so don't put middleware in front of it that reads or rewrites the body (such as decompression, or middleware that reads the body for logging without putting it back), and don't let gateways or reverse proxies change the body.
For Gin, use gin.WrapH:
package main
import (
"log"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/DeepRespond-AI/deeprespond-im-server-go-sdk/callback"
)
func main() {
h, err := callback.NewHandler(callback.Config{
Secrets: callback.StaticSecrets{os.Getenv("IM_CALLBACK_ENDPOINT_ID"): {os.Getenv("IM_CALLBACK_SECRET")}},
Nonces: callback.NewMemoryNonceStore(),
Dedup: callback.NewMemoryDedupStore(24 * time.Hour),
Events: callback.EventHandlers{ /* …… */ },
})
if err != nil {
log.Fatal(err)
}
r := gin.New()
r.POST("/im/callback", gin.WrapH(h))
log.Fatal(r.Run(":8090"))
}For Echo, use e.POST("/im/callback", echo.WrapHandler(h)).
In environments that read the request themselves, such as cloud functions, use the framework-independent HandleRequest: pass in the headers and raw body, and get back the status code, response headers and response body.
// 云函数的入口:headers 为请求头,body 为原始请求体(如果平台给的是 base64,先解码)
func handleCallback(ctx context.Context, h *callback.Handler, headers map[string]string, body []byte) (int, map[string]string, string) {
header := http.Header{}
for k, v := range headers {
header.Set(k, v)
}
status, respHeader, respBody := h.HandleRequest(ctx, header, body)
out := map[string]string{}
for k := range respHeader {
out[k] = respHeader.Get(k)
}
return status, out, string(respBody)
}Status codes returned
| Case | Status |
|---|---|
All events handled (including duplicates, those passed to Unknown, or ignored) | 204 |
| URL verification | 200, {"challenge":"..."} |
| Sync callback | 200 with the result; 503 when the handler errors, times out or returns an invalid result and no HookFallback is set |
Signature, timestamp or nonce check failed, endpoint ID not configured, or endpoint_id in the body differs from the header | 401 |
The body isn't a valid callback request (not JSON, unknown kind, an event missing id or type) | 400 |
The body is larger than MaxBodyBytes | 413 |
Not a POST | 405 |
An event failed, is being handled by another request, or failed to enqueue; a store or SecretProvider error | 503, and the server resends later |
The server retries on any non-2xx. With a misconfigured secret, the same batch is retried for 24 hours, and endpoints that keep failing are disabled automatically (see Delivery and retries). When signature verification fails, the SDK logs an error describing likely causes (wrong secret, body changed in transit, local clock skew); alert on it.
Handle event callbacks
callback.EventHandlers has a field per event type, with signature func(ctx context.Context, ev *callback.Event, d *Type) error, where d is the data already parsed for that event type:
func eventHandlers() callback.EventHandlers {
return callback.EventHandlers{
GroupMembersAdded: func(ctx context.Context, ev *callback.Event, d *callback.GroupMembersAdded) error {
// 不保证顺序:用版本号判断新旧,旧事件不覆盖新状态
return saveMemberCount(ctx, d.GroupID, d.MemberVersion, d.MemberCount)
},
UserStatusChanged: func(ctx context.Context, ev *callback.Event, d *callback.UserStatusChanged) error {
if ev.Test {
return nil // 控制台“测试发送”的事件,不执行业务操作
}
if d.Status == "deleted" {
return deleteLocalProfile(ctx, d.Username)
}
return nil
},
// 所有事件(含不认识的)先交给 Any,返回错误即失败;用于统一记录或转发
Any: func(ctx context.Context, ev *callback.Event) error {
slog.DebugContext(ctx, "收到事件", "id", ev.ID, "type", ev.Type, "attempt", ev.Attempt)
return nil
},
// SDK 还不认识的事件类型(服务端新增的);不设置时忽略
Unknown: func(ctx context.Context, ev *callback.Event) error {
slog.WarnContext(ctx, "SDK 还不认识的事件", "type", ev.Type, "data", string(ev.Data))
return nil
},
}
}- Return
nilon success. Returning an error makes the batch return503, and the server resends the whole batch later (events already handled are skipped). If a handler panics, the SDK recovers and treats it as a failure. - The server only waits 5 seconds by default (1 to 10 seconds per endpoint), and a timeout counts as failure. Keep handlers fast: write a record, update a cache; for slow work, use the asynchronous approach.
- To delay the resend, return an error wrapped with
callback.RetryAfter(d, err): the response carriesRetry-After(the longest in the batch, up to 1 hour), and the server delays the resend accordingly. - Event types without a handler count as succeeded.
callback.Event fields:
| Field | Description |
|---|---|
ID | Event ID; stays the same when the event arrives several times (retries, redelivery) |
Type | Event type, such as message.sent |
OccurredAt | When it happened |
Attempt | Which delivery this is, starting at 1 (restarting after redelivery) |
Test | Whether the event came from "Send test" in the console |
Data | The raw JSON of data, byte for byte as in the request body |
Raw | This event's raw JSON in the request body |
Every event's data type embeds callback.EventBase: Origin (who caused it: client, server, system, platform and so on; nil for presence changes), Truncated (data was over 70 KB and truncated, with removed fields left at zero values; read them through OpenAPI by ID when needed), and Raw (the raw JSON of data, for fields the SDK doesn't parse). Field names are converted from JSON (sender_username becomes SenderUsername), fields that can be null are pointers, and free-form content such as message Body and Ext is json.RawMessage. For what each field means, see Events.
ev.Decode() parses Data into the type matching Type (a pointer, such as *callback.MessageSent), returning callback.ErrUnknownEventType for unknown types; use it to dispatch by type in Any or in asynchronous workers. callback.EventTypes lists every event type the SDK knows.
De-duplicating by event ID
Delivery is at-least-once: the same event may arrive several times through retries and redelivery, with the same event ID (the ID is also the same when one event goes to several endpoints of the same app). Before handling each event, the SDK checks whether it was already handled:
- Events already handled are skipped and count as success (return
2xx, never an error, or the server keeps retrying). - Before handling, the event ID is claimed (valid for
ClaimTTL); after success it's marked done, and on failure it's released. - If the same event is being handled in another request (an earlier attempt was slow, timed out on the server, and was resent), the batch returns
503and the server resends later.
NewMemoryDedupStore(retention) keeps finished event IDs in process memory (24 hours when retention is 0), up to 1 million, evicting the oldest with a warning log when full. When deploying several instances, or when you need to redeliver events from days ago in the console, switch to shared storage and keep IDs for 7 days (the console keeps failed deliveries for 7 days); see Nonce and dedup storage.
Order and truncation
- Events aren't ordered, not even within a batch. Use version numbers (
Version,MemberVersion,InfoVersion, messages'Seqand so on) andOccurredAtto tell newer from older, and never let an older event overwrite newer state. The SDK neither caches nor reorders events. - Check
Truncatedfirst: whentrue, objects, arrays and long text fields were removed. For message events, also checkContentAvailable: whenfalse, the message was recalled, erased or expired, andBodyandExtarenil. - Presence changes, chat room message copies and chat room recalls are best-effort events that may be lost, so don't rely on them for reconciliation.
Asynchronous handling
For high-volume message copies or slow processing, go asynchronous: set Queue instead of Events. The SDK only verifies, de-duplicates and claims, then hands events to your queue; once enqueued the event is marked done, and the response is 2xx once all are enqueued.
// 把事件写入数据库的待处理表(必须是持久的:入队后服务端不会再为这个事件重发)
type tableQueue struct{ db *sql.DB }
func (q tableQueue) Enqueue(ctx context.Context, ev *callback.Event) error {
_, err := q.db.ExecContext(ctx,
"INSERT INTO im_events (id, type, occurred_at, data) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE id = id",
ev.ID, ev.Type, ev.OccurredAt, []byte(ev.Raw))
return err
}
func newAsyncHandler(db *sql.DB, secrets callback.SecretProvider) (*callback.Handler, error) {
return callback.NewHandler(callback.Config{
Secrets: secrets,
Nonces: callback.NewMemoryNonceStore(),
Dedup: callback.NewMemoryDedupStore(24 * time.Hour),
Queue: tableQueue{db: db},
})
}- Events that fail to enqueue are released, the batch returns
503, and the server resends later. - Processing and retries afterward are your queue's job (at least once); an in-memory channel won't do.
- One handler uses either
EventsorQueue. To handle types separately, dispatch in your worker withev.Decode().
Event types and handlers
| Event | Handler field and data type | Description |
|---|---|---|
user.created | UserCreated | A user registered (signed up from a client, or created by your server or the console) |
user.status_changed | UserStatusChanged | A user was banned, unbanned or deleted |
user.profile_changed | UserProfileChanged | A user changed their nickname, avatar or custom attributes; the new values aren't included, so read them through OpenAPI when needed |
user.mute_changed | UserMuteChanged | A global mute was set or lifted |
user.session_created | UserSessionCreated | A user signed in on a device |
user.sessions_revoked | UserSessionsRevoked | Sign-in sessions were revoked: signed out, kicked, pushed off by another sign-in, expired and so on |
user.relogin_required | UserReloginRequired | Every user in the app must sign in again |
presence.changed | PresenceChanged | A user's online platforms changed: came online, went offline, or gained or lost a platform; best-effort |
friend.added | FriendAdded | Two users became friends (one event for each side) |
friend.removed | FriendRemoved | A friendship ended (one event for each side; reason is user_deleted when the other account was deleted) |
friend.updated | FriendUpdated | A friend's remark or custom attributes changed |
friend_request.received | FriendRequestReceived | A friend request that needs approval arrived |
friend_request.declined | FriendRequestDeclined | A friend request was declined |
blacklist.changed | BlacklistChanged | Someone was blocked or unblocked |
group.created | GroupCreated | A group was created |
group.info_changed | GroupInfoChanged | Group profile, settings, mute-all or owner changed, or the group was banned or unbanned |
group.dismissed | GroupDismissed | A group was dissolved |
group.members_added | GroupMembersAdded | Members joined (one event per operation) |
group.members_removed | GroupMembersRemoved | Members left, were removed, were added to the group block list, or had their accounts deleted |
group.members_updated | GroupMembersUpdated | A member's role, group nickname, member attributes or mute changed |
group.request_created | GroupRequestCreated | A new join request or invitation |
group.request_handled | GroupRequestHandled | A join request or invitation was accepted, declined, withdrawn or expired |
message.sent | MessageSent | Message copy: one event per one-to-one or group message written (group notices aren't sent by default) |
message.recalled | MessageRecalled | A message was recalled (sent even when "don't receive events caused by the tenant's server" is on) |
message.edited | MessageEdited | A message was edited by the server |
chatroom.created | ChatroomCreated | A chat room was created |
chatroom.dismissed | ChatroomDismissed | A chat room was dissolved |
chatroom.info_changed | ChatroomInfoChanged | A chat room's profile, settings, owner or admins changed, or it was banned or unbanned |
chatroom.members_muted | ChatroomMembersMuted | Chat room members were muted |
chatroom.members_unmuted | ChatroomMembersUnmuted | Chat room members were unmuted |
chatroom.members_kicked | ChatroomMembersKicked | Chat room members were removed |
chatroom.members_banned | ChatroomMembersBanned | Chat room members were banned |
chatroom.members_unbanned | ChatroomMembersUnbanned | Chat room members were unbanned |
chatroom.allowlist_changed | ChatroomAllowlistChanged | A chat room's allowlist changed |
chatroom.attributes_changed | ChatroomAttributesChanged | A chat room's attributes changed |
chatroom.message_sent | ChatroomMessageSent | Chat room message copy: one event per message pushed out; best-effort |
chatroom.message_recalled | ChatroomMessageRecalled | A chat room message was recalled; best-effort, and sent even when "don't receive events caused by the tenant's server" is on |
rtc.call_created | RtcCallCreated | A call was started |
rtc.call_answered | RtcCallAnswered | A call was answered |
rtc.member_joined | RtcMemberJoined | A member joined a group call |
rtc.member_left | RtcMemberLeft | A member left a group call |
rtc.call_ended | RtcCallEnded | A call ended, with its duration |
moderation.item_created | ModerationItemCreated | A new item awaits review (tenant queue); the content isn't included, so read it through OpenAPI by item_id |
moderation.item_decided | ModerationItemDecided | A moderation item got a decision |
moderation.report_created | ModerationReportCreated | A user reported something |
moderation.violation_recorded | ModerationViolationRecorded | A violation was recorded (which may trigger a penalty) |
media.file_uploaded | MediaFileUploaded | A file finished uploading (except business verification documents) |
media.file_blocked | MediaFileBlocked | A file was blocked |
media.file_unblocked | MediaFileUnblocked | A file was unblocked |
Handle sync callbacks
Sync callbacks call your receiver before an operation completes, and you decide whether to allow, reject or modify it; see Sync callbacks. callback.HookHandlers has a handler per sync callback, returning a callback.Verdict:
| Handler | When | Possible results |
|---|---|---|
MessageBeforeSend | Before one-to-one and group messages are sent or edited | Allow, Reject, Modify |
ChatroomBeforeSend | Before chat room messages are sent | Allow, Reject, Modify |
FriendBeforeAdd | Before a client sends or accepts a friend request | Allow, Reject |
GroupBeforeJoin | Before a client creates a group, invites, requests to join, or joins by link | Allow, Reject; creating and inviting also allow Partial |
func hookHandlers() callback.HookHandlers {
return callback.HookHandlers{
ChatroomBeforeSend: func(ctx context.Context, req *callback.ChatroomBeforeSend) (callback.Verdict, error) {
var body struct {
Text string `json:"text"`
}
if err := json.Unmarshal(req.Body, &body); err != nil || body.Text == "" {
return callback.Allow(), nil
}
masked := strings.ReplaceAll(body.Text, "傻", "*")
if masked == body.Text {
return callback.Allow(), nil
}
// 替换消息内容;ext 不变
newBody, err := json.Marshal(map[string]string{"text": masked})
if err != nil {
return callback.Verdict{}, err
}
return callback.Modify(newBody, drim.Field[json.RawMessage]{}), nil
},
GroupBeforeJoin: func(ctx context.Context, req *callback.GroupBeforeJoin) (callback.Verdict, error) {
if req.JoinedVia != "invite" && req.JoinedVia != "create" {
return callback.Allow(), nil
}
// 邀请和建群可以只拒绝其中一部分人
var rejected []callback.RejectedTarget
for _, username := range req.Targets {
if strings.HasPrefix(username, "guest_") {
rejected = append(rejected, callback.RejectedTarget{Username: username, Reason: "guest_not_allowed", Message: "访客不能加入群聊"})
}
}
if len(rejected) == 0 {
return callback.Allow(), nil
}
return callback.Partial(rejected...), nil
},
}
}Result constructors:
| Function | Description |
|---|---|
callback.Allow() | Allow |
callback.Reject(reason, message) | Reject. reason is 1 to 64 lowercase letters, digits or _, ., -, and the client gets it in the error's details.app_reason; message is a hint for the user, up to 256 characters with no control characters (including newlines and tabs). Both can be empty |
callback.Modify(body, ext) | Modify the message; only for the two before-send callbacks. body nil means unchanged, otherwise it must be a JSON object; ext is a drim.Field[json.RawMessage]: the zero value leaves it unchanged, drim.Null[json.RawMessage]() removes ext, and drim.Set(v) replaces it. At least one must change |
callback.Partial(rejected...) | Reject some people; only for invitations and group creation in GroupBeforeJoin (JoinedVia invite or create). At least one entry, and the usernames must be in req.Targets |
The ext parameter type of Modify comes from the drim package; import drim "github.com/DeepRespond-AI/deeprespond-im-server-go-sdk" to use it. Modified messages don't go through content moderation again; the server re-validates them under the normal rules, and the type can't change.
Time limits and failures
- The
ctxhandlers receive has a deadline: the request'stimeout_ms(how long the server actually waits this time, 1000 milliseconds by default) minus 50 milliseconds.req.Timeoutis the wait the server gave. Use thisctxfor database queries and calls to other services. - When a handler times out, returns an error, panics or returns an invalid result,
HookFallbackis returned; withoutHookFallback, the response is503and the server applies the failure policy the app set for this callback (allow or reject). After a timeout the SDK stops waiting for the handler and discards its result. - With
HookFallbackset toAllow(), errors and timeouts are allowed without depending on the server's failure policy. Returning503counts toward the server's circuit breaker, and with persistent errors the server temporarily stops calling your receiver. - A handler returning the zero
Verdict(forgetting to build a result) isn't treated as allow; it's treated as an error. - A sync callback with no handler set also returns
HookFallback(or503) and logs an error. Only turn on sync callbacks in the console that you have handlers for. - The SDK checks results before returning them: the format of
reasonandmessage,Modify's content,Partial's usernames, whether the action fits this callback, and that the response is at most 64 KB. Invalid results aren't sent; they're treated as errors and logged, so you can spot your own bugs. - When more than
MaxConcurrentHookshandlers (100 by default) are running, new requests returnHookFallback(or503) straight away.
Sync callbacks directly affect how fast users can send messages and add friends, and a slow or failing receiver affects every user. Only do quick local checks in handlers. When first integrating, set the failure policy to allow in the console, and switch to reject later if needed once things are stable.
The request types (MessageBeforeSend and so on) all embed callback.HookBase: Test (a request from "Send test" in the console), Timeout, and Raw (the raw data). For what each field means, see Sync callbacks.
URL verification
When you create or change a callback endpoint in the console, the server sends a URL verification request. The handler verifies the signature and returns 200 with {"challenge":"<value from the request>"} automatically, without calling your code; see URL verification. So before verification, the receiver must already be deployed with this endpoint's secret configured.
The order to set things up: deploy the receiver (with only storage and secrets configured) → create the endpoint in the console and put the secret it shows into Secrets → confirm the signature passes with "Send test" in the console → complete URL verification → subscribe to events → turn on sync callbacks with allow-on-failure first.
Nonce and dedup storage
NewMemoryNonceStore and NewMemoryDedupStore only work within one process. With several instances or servers, the same event or a replayed request may reach another instance, so switch to shared storage by implementing two interfaces:
// NonceStore 记录 10 分钟内见过的随机数:已存在返回 true,否则记下(ttl 后过期)并返回 false
type NonceStore interface {
Seen(ctx context.Context, nonce string, ttl time.Duration) (bool, error)
}
// DedupStore 按事件 ID 去重:开始处理时占用(得到占用的令牌),成功后改为已完成,失败时释放;
// 完成和释放只在令牌相同时生效(占用过期后被别的请求重新占用时,不改掉别人的占用)
type DedupStore interface {
Claim(ctx context.Context, eventID string, ttl time.Duration) (state callback.ClaimState, token string, err error)
Complete(ctx context.Context, eventID, token string) error
Release(ctx context.Context, eventID, token string) error
}Claim results: callback.Acquired (claimed, go ahead), callback.Done (already handled, skip), callback.Busy (being handled in another request).
When a store errors, the batch returns 503 and the server resends later: if the nonce store errors, replay can't be ruled out, so it's better to have the server resend. The in-memory nonce store holds up to 1 million nonces and returns callback.ErrStoreFull when full (also 503), without evicting unexpired nonces.
An implementation with go-redis (keeping finished event IDs for 7 days):
package callbackstore
import (
"context"
"crypto/rand"
"encoding/hex"
"time"
"github.com/redis/go-redis/v9"
"github.com/DeepRespond-AI/deeprespond-im-server-go-sdk/callback"
)
// RedisStores 同时实现 callback.NonceStore 和 callback.DedupStore。
type RedisStores struct {
R *redis.Client
Prefix string // 如 "im-callback:"
}
var (
_ callback.NonceStore = RedisStores{}
_ callback.DedupStore = RedisStores{}
)
func (s RedisStores) Seen(ctx context.Context, nonce string, ttl time.Duration) (bool, error) {
ok, err := s.R.SetNX(ctx, s.Prefix+"nonce:"+nonce, 1, ttl).Result()
return !ok, err
}
var claimScript = redis.NewScript(`
local v = redis.call('GET', KEYS[1])
if not v then redis.call('SET', KEYS[1], 'busy:' .. ARGV[1], 'PX', ARGV[2]) return 'acquired' end
if v == 'done' then return 'done' end
return 'busy'`)
var completeScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == 'busy:' .. ARGV[1] then
redis.call('SET', KEYS[1], 'done', 'PX', ARGV[2]) return 1
end
return 0`)
var releaseScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == 'busy:' .. ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0`)
func (s RedisStores) Claim(ctx context.Context, eventID string, ttl time.Duration) (callback.ClaimState, string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, "", err
}
token := hex.EncodeToString(b[:])
r, err := claimScript.Run(ctx, s.R, []string{s.Prefix + "event:" + eventID}, token, ttl.Milliseconds()).Text()
switch {
case err != nil:
return 0, "", err
case r == "acquired":
return callback.Acquired, token, nil
case r == "done":
return callback.Done, "", nil
}
return callback.Busy, "", nil
}
func (s RedisStores) Complete(ctx context.Context, eventID, token string) error {
keep := 7 * 24 * time.Hour
return completeScript.Run(ctx, s.R, []string{s.Prefix + "event:" + eventID}, token, keep.Milliseconds()).Err()
}
func (s RedisStores) Release(ctx context.Context, eventID, token string) error {
return releaseScript.Run(ctx, s.R, []string{s.Prefix + "event:" + eventID}, token).Err()
}Pass Nonces: stores, Dedup: stores when creating the handler (stores := RedisStores{R: rdb, Prefix: "im-callback:"}).
Without the handler
To control the whole flow yourself, use only the verification and parsing functions, and do nonce checks and event de-duplication on your own:
| Function | Description |
|---|---|
callback.Verify(secrets, header, body, now) | Verifies the signature and timestamp, with secrets the endpoint's list of secrets; on failure returns *callback.VerifyError with Reason bad_signature, bad_timestamp, stale_timestamp or bad_format |
callback.Parse(body) | Parses the body into *callback.Request (without verifying the signature; call Verify first) |
callback.Sign(secret, timestamp, nonce, body) | Computes a signature (lowercase hex, without v1=) |
callback.SignatureHeader(secrets, timestamp, nonce, body) | The value of X-IM-Signature, with one v1= per secret |
func verifyAndParse(r *http.Request, secrets []string) (*callback.Request, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20))
if err != nil {
return nil, err
}
if err := callback.Verify(secrets, r.Header, body, time.Now()); err != nil {
var verr *callback.VerifyError
if errors.As(err, &verr) {
log.Printf("签名校验失败:%s", verr.Reason)
}
return nil, err
}
// 校验通过后:记录 X-IM-Nonce(10 分钟内重复的拒绝)、核对 endpoint_id,再按 Kind 处理
return callback.Parse(body)
}callback.Request has Kind event, hook or verify; Events is the event list; Hook, Data and TimeoutMs are the sync callback content; and Challenge is URL verification's random string. Within the handler's callbacks, callback.RequestFrom(ctx) returns the request's common information (RequestID, App, EndpointID, SentAt and so on, without the events and data).
Local testing
The drimtest package builds correctly signed callback requests, so you can test your handlers without a real server:
package receiver_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/DeepRespond-AI/deeprespond-im-server-go-sdk/callback"
"github.com/DeepRespond-AI/deeprespond-im-server-go-sdk/drimtest"
)
const secret = "cbs_test_secret_for_unit_tests_0000000000000000"
func newHandler(t *testing.T, events callback.EventHandlers, hooks callback.HookHandlers) *callback.Handler {
t.Helper()
h, err := callback.NewHandler(callback.Config{
Secrets: callback.StaticSecrets{drimtest.DefaultEndpointID: {secret}},
Nonces: callback.NewMemoryNonceStore(),
Dedup: callback.NewMemoryDedupStore(time.Hour),
Events: events,
Hooks: hooks,
})
if err != nil {
t.Fatal(err)
}
return h
}
func TestUserCreatedIsHandledOnce(t *testing.T) {
created := 0
h := newHandler(t, callback.EventHandlers{
UserCreated: func(ctx context.Context, ev *callback.Event, d *callback.UserCreated) error {
created++
return nil
},
}, callback.HookHandlers{})
body := drimtest.EventRequest(drimtest.Event{
ID: "1840300000000000001",
Type: "user.created",
Data: map[string]any{"origin": "server", "username": "zhangsan", "created_via": "openapi"},
})
for i := 0; i < 2; i++ { // 同一事件到达两次:只处理一次,两次都返回 204
rec := httptest.NewRecorder()
h.ServeHTTP(rec, drimtest.SignedRequest(drimtest.DefaultEndpointID, body, secret))
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d", rec.Code)
}
}
if created != 1 {
t.Fatalf("handled %d times", created)
}
}
func TestRejectBannedWord(t *testing.T) {
h := newHandler(t, callback.EventHandlers{}, callback.HookHandlers{
MessageBeforeSend: func(ctx context.Context, req *callback.MessageBeforeSend) (callback.Verdict, error) {
if strings.Contains(string(req.Body), "加微信") {
return callback.Reject("forbidden_word", "消息中有不允许的内容"), nil
}
return callback.Allow(), nil
},
})
body := drimtest.HookRequest("message.before_send", map[string]any{
"via": "client", "client_msg_id": "c1", "conversation_type": "single",
"sender_username": "zhangsan", "recipient_username": "lisi",
"type": "text", "body": map[string]any{"text": "加微信 123"},
})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, drimtest.SignedRequest(drimtest.DefaultEndpointID, body, secret))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"action":"reject"`) {
t.Fatalf("status %d, body %s", rec.Code, rec.Body)
}
}
func TestWrongSecretIsRejected(t *testing.T) {
h := newHandler(t, callback.EventHandlers{}, callback.HookHandlers{})
body := drimtest.VerifyRequest("challenge-1")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, drimtest.SignedRequest(drimtest.DefaultEndpointID, body, "cbs_another_secret"))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status %d", rec.Code)
}
}| Function | Description |
|---|---|
drimtest.EventRequest(events...) | An event callback body with endpoint ID drimtest.DefaultEndpointID; a drimtest.Event without ID gets a random one, and Data as string, []byte or json.RawMessage is used as is, other values encoded as JSON |
drimtest.EventRequestFor(endpointID, events...) | An event callback body for a given endpoint ID |
drimtest.HookRequest(hook, data), HookRequestFor(endpointID, hook, timeoutMs, data) | A sync callback body, with timeout_ms 1000 by default |
drimtest.VerifyRequest(challenge) | A URL verification body |
drimtest.SignedRequest(endpointID, body, secrets...) | A correctly signed *http.Request (current time, new nonce); during rotation you can pass both old and new secrets |
drimtest.SignedHeader(endpointID, body, at, nonce, secrets...) | Headers with a given timestamp and nonce, for testing expiry and replay |
drimtest.SignatureVectors() | Signature test vectors |
Signature test vectors
drimtest.SignatureVectors() returns a set of signature test vectors, using the same data as the server and the examples in Signatures and security: valid signatures, two signatures during rotation, multi-byte characters and unescaped <>&, timestamps with leading zeros or a plus sign, expired timestamps, a body with one byte changed, and so on. When implementing signature verification yourself (or on a gateway), use them to confirm it's correct:
func TestSignatureVectors(t *testing.T) {
for _, v := range drimtest.SignatureVectors() {
err := callback.Verify([]string{v.Secret}, v.Header(), v.Body(), time.Unix(v.Now, 0))
var verr *callback.VerifyError
switch {
case v.Valid && err != nil:
t.Errorf("%s: want valid, got %v", v.Name, err)
case !v.Valid && (!errors.As(err, &verr) || verr.Reason != v.Error):
t.Errorf("%s: want %s, got %v", v.Name, v.Error, err)
}
}
}Each vector has Name, Secret (the secret configured on the receiver), Timestamp, Nonce, Body() (the raw body), Header() (the headers), Now (the current time at verification), Valid, and Error (the expected reason when it fails).
Once connected to the real server, confirm the signature passes with "Send test" on the callback endpoint in the console. When verification fails, the SDK's error log describes likely causes, most commonly a misconfigured secret or a gateway or framework changing the body; see Debugging signatures.
Standalone channel callbacks
| Event | Type |
|---|---|
rtc_channel.created | RTCChannelCreated |
rtc_channel.updated | RTCChannelUpdated |
rtc_channel.closing | RTCChannelClosing |
rtc_channel.closed | RTCChannelClosed |
rtc_channel.session_joined | RTCChannelSessionJoined |
rtc_channel.session_leaving | RTCChannelSessionLeaving |
rtc_channel.session_left | RTCChannelSessionLeft |
rtc_channel.member_banned | RTCChannelMemberBanned |
rtc_channel.member_unbanned | RTCChannelMemberUnbanned |
These 9 events are dispatched by the handler like any other, with the same signature verification, de-duplication and error handling. For fields, see Channel events; some fields may be absent, and apart from session events, don't assume an event includes a target user. Channel events have separate names and types from call events (rtc.call_*), and internal broadcasts of config changes aren't sent as callbacks.
Game room callbacks
The 7 game room events are dispatched like any other event, with the same signature checks, deduplication and error handling. Most games only need game_room.closed: it carries the result and every player's online time, for rewards, match history and billing reconciliation.
handler, err := callback.NewHandler(callback.Config{
Secrets: callback.StaticSecrets{endpointID: {secret}},
Nonces: callback.NewMemoryNonceStore(),
Dedup: callback.NewMemoryDedupStore(0),
Events: callback.EventHandlers{
GameRoomClosed: func(ctx context.Context, ev *callback.Event, d *callback.GameRoomClosed) error {
// d.Result 是结束房间时给的结算结果;超时、全员离开等自动结束时为 null
if err := saveMatch(ctx, d.RoomID, d.CloseReason, d.Result); err != nil {
return err // 返回错误时 IM 稍后重新投递
}
for _, m := range d.Members {
if err := addPlayMinutes(ctx, m.Username, m.BillableMinutes); err != nil {
return err
}
}
return nil
},
GameRoomMemberLeft: func(ctx context.Context, ev *callback.Event, d *callback.GameRoomMemberLeft) error {
log.Printf("%s 离开了 %s:%s", d.Username, d.RoomID, d.Reason)
return nil
},
},
})| Event | Handler field and data type | Description |
|---|---|---|
game_room.created | GameRoomCreated | A room was created (assigned to a game node) |
game_room.started | GameRoomStarted | The match started (the game node began advancing frames) |
game_room.member_joined | GameRoomMemberJoined | A player got a seat |
game_room.member_connected | GameRoomMemberConnected | A player connected to the game node for the first time; frequent, subscribe only if needed |
game_room.member_disconnected | GameRoomMemberDisconnected | A player disconnected from the game node (can recover within the reconnect window); frequent, subscribe only if needed |
game_room.member_left | GameRoomMemberLeft | A player left (left, timed out, was removed or did not connect in time) |
game_room.closed | GameRoomClosed | The room ended, with duration, result, and each player's online time and billable minutes |
- Events can arrive more than once or out of order: the handler deduplicates by event ID; in your own logic use
Version(the room version, incremented on every state change) to tell newer from older. ResultandExtarejson.RawMessage; when the room closes automatically (timeout, everyone left)Resultisnull.BillableMinutesis this match's minutes per player, rounded up, for your own timing and reconciliation; invoices sum per day and then round, see Billing.- Field meanings are in game room callback events.
