package tencentim import ( "context" "encoding/json" "fmt" "os" "strconv" "strings" "testing" "time" ) func TestRealTencentIMCreateSendAndDestroyGroup(t *testing.T) { if os.Getenv("TENCENT_IM_REAL_TEST") != "1" { t.Skip("set TENCENT_IM_REAL_TEST=1 and Tencent IM env vars to run the real REST smoke test") } cfg := realRESTConfigFromEnv(t) client, err := NewRESTClient(cfg) if err != nil { t.Fatalf("NewRESTClient failed: %v", err) } now := time.Now().UTC() nowMS := now.UnixMilli() groupID := fmt.Sprintf("hy_codex_bc_r_%d", nowMS) eventID := fmt.Sprintf("codex_real_im_%d", nowMS) ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() // 真实 IM 测试必须使用临时 GroupID,避免污染业务全局/区域播报群。 if err := client.EnsureGroup(ctx, GroupSpec{ GroupID: groupID, Name: "codex real im smoke", Type: DefaultGroupType, ApplyJoinOption: "FreeAccess", }); err != nil { t.Fatalf("real Tencent IM create group failed: %v", err) } t.Cleanup(func() { cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 20*time.Second) defer cleanupCancel() if err := client.DestroyGroup(cleanupCtx, groupID); err != nil { t.Errorf("real Tencent IM destroy group cleanup failed: %v", err) } }) memberUserID := nowMS % 9_000_000_000 if err := realImportAccount(ctx, client, memberUserID); err != nil { t.Fatalf("real Tencent IM account import failed: %v", err) } if err := realAddGroupMember(ctx, client, groupID, memberUserID); err != nil { t.Fatalf("real Tencent IM add group member failed: %v", err) } const actorUserID = "323411852102995968" const targetUserID = "323411852102995969" c2cEventID := fmt.Sprintf("codex_real_im_c2c_%d", nowMS) c2cPayload, err := json.Marshal(map[string]any{ "event_id": c2cEventID, "event_type": "room_user_kicked", "actor_user_id": actorUserID, "target_user_id": targetUserID, "sender_user_id": actorUserID, "receiver_user_id": targetUserID, "sent_at_ms": nowMS, }) if err != nil { t.Fatalf("marshal c2c smoke payload failed: %v", err) } if err := client.PublishUserCustomMessage(ctx, CustomUserMessage{ ToAccount: strconv.FormatInt(memberUserID, 10), EventID: c2cEventID, Desc: "codex_real_im_c2c_probe", Ext: "im_c2c_smoke", PayloadJSON: c2cPayload, }); err != nil { t.Fatalf("real Tencent IM send C2C custom message failed: %v", err) } // SyncOtherMachine=2 不同步管理员发送端,因此漫游消息必须从实际接收用户视角回读。 c2cData, c2cCloudData := realWaitForC2CPayload(t, ctx, client, strconv.FormatInt(memberUserID, 10), cfg.AdminIdentifier, c2cEventID, now.Unix()) realAssertStringUserIDs(t, c2cData, map[string]string{ "actor_user_id": actorUserID, "target_user_id": targetUserID, "sender_user_id": actorUserID, "receiver_user_id": targetUserID, }) realAssertStringUserIDs(t, c2cCloudData, map[string]string{ "actor_user_id": actorUserID, "target_user_id": targetUserID, "sender_user_id": actorUserID, "receiver_user_id": targetUserID, }) roomEvent := RoomEvent{ GroupID: groupID, EventID: eventID, RoomID: "codex-room-smoke", EventType: "room_mic_change", ActorUserID: actorUserID, TargetUserID: targetUserID, SeatNo: 3, RoomVersion: 22, Attributes: map[string]string{ "action": "change", "from_seat": "1", "to_seat": "3", "mic_session_id": "codex-real-im-mic-session", "target_user_ids": `["323411852102995968","323411852102995969"]`, }, } // 使用生产 RoomEvent 序列化和发送入口,避免真实测试绕开本次修复的代码路径。 if err := client.PublishRoomEvent(ctx, roomEvent); err != nil { t.Fatalf("real Tencent IM send group custom message failed: %v", err) } groupData, groupCloudData := realWaitForGroupPayload(t, ctx, client, groupID, eventID) for source, payload := range map[string]string{"Data": groupData, "CloudCustomData": groupCloudData} { realAssertStringUserIDs(t, payload, map[string]string{ "actor_user_id": actorUserID, "target_user_id": targetUserID, }) var decoded map[string]any if err := json.Unmarshal([]byte(payload), &decoded); err != nil { t.Fatalf("decode group %s payload failed: %v", source, err) } attributes, ok := decoded["attributes"].(map[string]any) if !ok { t.Fatalf("group %s payload attributes type = %T, want object", source, decoded["attributes"]) } var targetUserIDs []any if err := json.Unmarshal([]byte(fmt.Sprint(attributes["target_user_ids"])), &targetUserIDs); err != nil { t.Fatalf("decode group %s target_user_ids failed: %v", source, err) } if len(targetUserIDs) != 2 || targetUserIDs[0] != actorUserID || targetUserIDs[1] != targetUserID { t.Fatalf("group %s target_user_ids = %#v, want exact string IDs", source, targetUserIDs) } } if err := client.DeleteGroupMember(ctx, groupID, memberUserID); err != nil { t.Fatalf("real Tencent IM delete group member failed: %v", err) } t.Logf("real Tencent IM transport and history readback passed: sdk_app_id=%d group_id=%s group_event_id=%s c2c_event_id=%s exact_actor_user_id=%s removed_user_id=%d", cfg.SDKAppID, groupID, eventID, c2cEventID, actorUserID, memberUserID) } type realHistoryMessage struct { MsgBody []messageElement `json:"MsgBody"` CloudCustomData string `json:"CloudCustomData"` } type realGroupHistoryResponse struct { restResponse RspMsgList []realHistoryMessage `json:"RspMsgList"` } type realC2CHistoryResponse struct { restResponse MsgList []realHistoryMessage `json:"MsgList"` } func realWaitForGroupPayload(t *testing.T, ctx context.Context, client *RESTClient, groupID, eventID string) (string, string) { t.Helper() for attempt := 0; attempt < 10; attempt++ { var response realGroupHistoryResponse if err := client.post(ctx, "v4/group_open_http_svc/group_msg_get_simple", map[string]any{ "GroupId": groupID, "ReqMsgNumber": 20, }, &response); err != nil { t.Fatalf("real Tencent IM group history request failed: %v", err) } if err := response.err(); err != nil { t.Fatalf("real Tencent IM group history failed: %v", err) } if data, cloudData, ok := realFindHistoryPayload(response.RspMsgList, eventID); ok { return data, cloudData } select { case <-ctx.Done(): t.Fatalf("real Tencent IM group history readback timed out: %v", ctx.Err()) case <-time.After(500 * time.Millisecond): } } t.Fatalf("real Tencent IM group history did not contain event_id=%s", eventID) return "", "" } func realWaitForC2CPayload(t *testing.T, ctx context.Context, client *RESTClient, operatorAccount, peerAccount, eventID string, sentAtSeconds int64) (string, string) { t.Helper() for attempt := 0; attempt < 10; attempt++ { var response realC2CHistoryResponse if err := client.post(ctx, "v4/openim/admin_getroammsg", map[string]any{ "Operator_Account": operatorAccount, "Peer_Account": peerAccount, "MaxCnt": 100, "MinTime": sentAtSeconds - 60, "MaxTime": time.Now().UTC().Unix() + 60, }, &response); err != nil { t.Fatalf("real Tencent IM C2C history request failed: %v", err) } if err := response.err(); err != nil { t.Fatalf("real Tencent IM C2C history failed: %v", err) } if data, cloudData, ok := realFindHistoryPayload(response.MsgList, eventID); ok { return data, cloudData } select { case <-ctx.Done(): t.Fatalf("real Tencent IM C2C history readback timed out: %v", ctx.Err()) case <-time.After(500 * time.Millisecond): } } t.Fatalf("real Tencent IM C2C history did not contain event_id=%s", eventID) return "", "" } func realFindHistoryPayload(messages []realHistoryMessage, eventID string) (string, string, bool) { for _, message := range messages { for _, element := range message.MsgBody { if element.MsgType != "TIMCustomElem" || !strings.Contains(element.MsgContent.Data, eventID) { continue } return element.MsgContent.Data, message.CloudCustomData, true } } return "", "", false } func realAssertStringUserIDs(t *testing.T, payload string, expected map[string]string) { t.Helper() var decoded map[string]any if err := json.Unmarshal([]byte(payload), &decoded); err != nil { t.Fatalf("decode Tencent IM payload failed: %v", err) } for key, want := range expected { got, ok := decoded[key].(string) if !ok { t.Fatalf("Tencent IM payload %s type = %T, want string", key, decoded[key]) } if got != want { t.Fatalf("Tencent IM payload %s = %q, want %q", key, got, want) } } } func realImportAccount(ctx context.Context, client *RESTClient, userID int64) error { return client.ImportAccount(ctx, AccountProfile{ UserID: userID, Nick: "codex-im-smoke", }) } func realAddGroupMember(ctx context.Context, client *RESTClient, groupID string, userID int64) error { var response restResponse if err := client.post(ctx, "v4/group_open_http_svc/add_group_member", map[string]any{ "GroupId": groupID, "Silence": 1, "MemberList": []map[string]any{ {"Member_Account": strconv.FormatInt(userID, 10)}, }, }, &response); err != nil { return err } return response.err() } func realRESTConfigFromEnv(t *testing.T) RESTConfig { t.Helper() sdkAppID, err := strconv.ParseInt(requiredEnv(t, "TENCENT_IM_SDK_APP_ID"), 10, 64) if err != nil || sdkAppID <= 0 { t.Fatalf("TENCENT_IM_SDK_APP_ID is invalid") } endpoint := strings.TrimSpace(os.Getenv("TENCENT_IM_ENDPOINT")) if endpoint == "" { endpoint = DefaultEndpoint } adminUserSigTTL := time.Hour if rawTTL := strings.TrimSpace(os.Getenv("TENCENT_IM_ADMIN_USERSIG_TTL")); rawTTL != "" { parsed, err := time.ParseDuration(rawTTL) if err != nil || parsed <= 0 { t.Fatalf("TENCENT_IM_ADMIN_USERSIG_TTL is invalid") } adminUserSigTTL = parsed } return RESTConfig{ SDKAppID: sdkAppID, SecretKey: requiredEnv(t, "TENCENT_IM_SECRET_KEY"), AdminIdentifier: requiredEnv(t, "TENCENT_IM_ADMIN_IDENTIFIER"), AdminUserSigTTL: adminUserSigTTL, Endpoint: endpoint, GroupType: DefaultGroupType, } } func requiredEnv(t *testing.T, key string) string { t.Helper() value := strings.TrimSpace(os.Getenv(key)) if value == "" { t.Fatalf("%s is required for real Tencent IM smoke test", key) } return value }