package com.xuqm.im.sdk; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.xuqm.common.model.ApiResponse; import com.xuqm.common.security.AppRequestSignatureUtil; import java.io.IOException; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Base64; import java.util.LinkedHashMap; import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.function.Supplier; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; public final class XuqmImServerSdk { private final HttpClient httpClient; private final ObjectMapper objectMapper; private final String baseUrl; private final String pushBaseUrl; private final String appKey; private final String appSecret; private final Supplier bearerTokenSupplier; private XuqmImServerSdk(Builder builder) { this.httpClient = HttpClient.newHttpClient(); this.objectMapper = new ObjectMapper() .registerModule(new JavaTimeModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); this.baseUrl = trimTrailingSlash(builder.baseUrl); this.pushBaseUrl = trimTrailingSlash(builder.pushBaseUrl == null ? builder.baseUrl : builder.pushBaseUrl); this.appKey = Objects.requireNonNull(builder.appKey, "appKey"); this.appSecret = Objects.requireNonNull(builder.appSecret, "appSecret"); this.bearerTokenSupplier = builder.bearerTokenSupplier; } public static Builder builder() { return new Builder(); } public String generateUserSig(String userId) { return generateUserSig(userId, 180L * 24 * 60 * 60, ""); } public String generateUserSig(String userId, long expireSeconds, String userBuf) { long issuedAt = Instant.now().getEpochSecond(); long expiresAt = issuedAt + Math.max(expireSeconds, 60L); Map payload = new LinkedHashMap<>(); payload.put("appKey", appKey); payload.put("userId", userId); payload.put("iat", issuedAt); payload.put("exp", expiresAt); payload.put("userBuf", userBuf == null ? "" : userBuf); payload.put("version", 1); String header = base64Url("{\"alg\":\"HS256\",\"typ\":\"UserSig\"}"); String body = base64Url(writeJson(payload)); String signature = base64Url(hmac(appSecret, header + "." + body)); return header + "." + body + "." + signature; } public boolean verifyUserSig(String userId, String userSig) { try { verifyUserSigClaims(userId, userSig); return true; } catch (Exception e) { return false; } } public UserSigClaims verifyUserSigClaims(String userId, String userSig) { String[] parts = normalize(userSig).split("\\."); if (parts.length != 3) { throw new IllegalArgumentException("Invalid UserSig format"); } String signingInput = parts[0] + "." + parts[1]; String expectedSignature = base64Url(hmac(appSecret, signingInput)); if (!MessageDigest.isEqual( expectedSignature.getBytes(StandardCharsets.UTF_8), parts[2].getBytes(StandardCharsets.UTF_8))) { throw new IllegalArgumentException("Invalid UserSig signature"); } JsonNode payload = readJson(base64UrlDecode(parts[1])); String tokenAppKey = text(payload, "appKey"); String tokenUserId = text(payload, "userId"); long issuedAt = payload.path("iat").asLong(0L); long expiresAt = payload.path("exp").asLong(0L); String userBuf = text(payload, "userBuf"); long now = Instant.now().getEpochSecond(); if (expiresAt > 0 && now > expiresAt) { throw new IllegalArgumentException("UserSig expired"); } if (!Objects.equals(appKey, tokenAppKey)) { throw new IllegalArgumentException("UserSig appKey mismatch"); } if (userId != null && !userId.isBlank() && !Objects.equals(userId, tokenUserId)) { throw new IllegalArgumentException("UserSig userId mismatch"); } return new UserSigClaims(tokenAppKey, tokenUserId, issuedAt, expiresAt, userBuf, normalize(userSig)); } public LoginResponse login(String userId) { return loginWithUserSig(userId, generateUserSig(userId)); } public LoginResponse loginWithUserSig(String userId, String userSig) { ApiResponse response = request( "POST", buildUri("/api/im/auth/login", Map.of("appKey", appKey, "userId", userId, "userSig", userSig)), null, null, new TypeReference<>() {} ); return response.data(); } public ImMessage sendMessage(SendMessageRequest request) { ApiResponse response = request( "POST", buildUri("/api/im/messages/send", Map.of("appKey", appKey)), request, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public ImMessage revokeMessage(String messageId) { ApiResponse response = request( "POST", buildUri("/api/im/messages/" + encode(messageId) + "/revoke", Map.of("appKey", appKey)), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public ImMessage editMessage(String messageId, String content) { ApiResponse response = request( "PUT", buildUri("/api/im/messages/" + encode(messageId), Map.of("appKey", appKey)), Map.of("content", content), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public AccountView importAccount(String userId, String nickname, String avatar, String gender, String status) { ApiResponse response = request( "POST", buildUri("/api/im/accounts/import", appQuery()), new ImportAccountRequest(userId, nickname, avatar, gender, status, null), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List importAccounts(List accounts) { ApiResponse> response = request( "POST", buildUri("/api/im/accounts/import/batch", appQuery()), accounts, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public void deleteAccount(String userId) { request( "DELETE", buildUri("/api/im/accounts/" + encode(userId), appQuery()), null, authorizedHeaders(), new TypeReference>() {} ); } public boolean checkAccount(String userId) { ApiResponse> response = request( "GET", buildUri("/api/im/accounts/" + encode(userId) + "/exists", appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); Object exists = response.data().get("exists"); return exists instanceof Boolean b && b; } public List listConversations(int size) { ApiResponse> response = request( "GET", buildUri("/api/im/conversations", Map.of("appKey", appKey, "page", "0", "size", String.valueOf(size))), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public PageResult fetchHistory(String toId, HistoryQuery query) { HistoryQuery effective = query == null ? new HistoryQuery() : query; ApiResponse> response = request( "GET", buildUri( "/api/im/messages/history/" + encode(toId), queryParams( effective.msgType(), effective.keyword(), effective.startTime(), effective.endTime(), effective.page() == null ? 0 : effective.page(), effective.size() == null ? 20 : effective.size() ) ), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public PageResult fetchGroupHistory(String groupId, HistoryQuery query) { HistoryQuery effective = query == null ? new HistoryQuery() : query; ApiResponse> response = request( "GET", buildUri( "/api/im/messages/group-history/" + encode(groupId), queryParams( effective.msgType(), effective.keyword(), effective.startTime(), effective.endTime(), effective.page() == null ? 0 : effective.page(), effective.size() == null ? 20 : effective.size() ) ), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public PageResult searchMessages(MessageSearchQuery query) { MessageSearchQuery effective = query == null ? new MessageSearchQuery() : query; ApiResponse> response = request( "GET", buildUri( "/api/im/messages/search", messageSearchQuery( effective.chatType(), effective.msgType(), effective.keyword(), effective.startTime(), effective.endTime(), effective.page() == null ? 0 : effective.page(), effective.size() == null ? 20 : effective.size() ) ), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List listWebhooks() { ApiResponse> response = request( "GET", buildUri("/api/im/admin/webhooks", appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public WebhookConfigView createWebhook(WebhookConfigRequest request) { ApiResponse response = request( "POST", buildUri("/api/im/admin/webhooks", appQuery()), request, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public WebhookConfigView updateWebhook(String id, WebhookConfigRequest request) { ApiResponse response = request( "PUT", buildUri("/api/im/admin/webhooks/" + encode(id), appQuery()), request, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public void deleteWebhook(String id) { request( "DELETE", buildUri("/api/im/admin/webhooks/" + encode(id), appQuery()), null, authorizedHeaders(), new TypeReference>() {} ); } public List listConversations() { return listConversations(20); } public AccountView getProfile(String userId) { ApiResponse response = request( "GET", buildUri("/api/im/accounts/" + encode(userId), appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public AccountView updateProfile(String userId, String nickname, String avatar, String gender) { Map query = appQuery(); if (nickname != null) { query.put("nickname", nickname); } if (avatar != null) { query.put("avatar", avatar); } if (gender != null) { query.put("gender", gender); } ApiResponse response = request( "PUT", buildUri("/api/im/accounts/" + encode(userId), query), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List searchAccounts(String keyword, int size) { ApiResponse> response = request( "GET", buildUri("/api/im/accounts/search", queryWithSize("keyword", keyword, size)), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public boolean verifyCallbackSignature(String timestamp, String nonce, String body, String signature) { long ts; try { ts = Long.parseLong(timestamp); } catch (NumberFormatException e) { return false; } long now = System.currentTimeMillis(); if (Math.abs(now - ts) > 5 * 60 * 1000L) { return false; } String payload = appKey + "\n" + timestamp + "\n" + normalize(nonce) + "\n" + sha256Hex(body); String expected = hmacSha256Hex(appSecret, payload); return MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), normalize(signature).getBytes(StandardCharsets.UTF_8) ); } public WebhookCallbackEnvelope parseCallbackEnvelope(String body) { try { JsonNode root = objectMapper.readTree(body); return new WebhookCallbackEnvelope( text(root, "callbackId"), text(root, "callbackType"), text(root, "callbackEvent"), longValue(root, "requestTime"), root.get("payload"), text(root, "signature"), text(root, "appKey") ); } catch (JsonProcessingException e) { throw new ImSdkException("Invalid callback body", e); } } public ImMessage parseMessageCallbackPayload(String body) { return parseMessageCallbackPayload(parseCallbackEnvelope(body)); } public ImMessage parseMessageCallbackPayload(WebhookCallbackEnvelope envelope) { if (!envelope.isMessageEvent() || !(envelope.isMessageSentEvent() || envelope.isMessageEditedEvent() || envelope.isMessageRevokedEvent())) { throw new ImSdkException("Callback event is not a message event"); } return readPayload(envelope, ImMessage.class); } public ImMessage parseMessageSentCallbackPayload(String body) { return parseMessageSentCallbackPayload(parseCallbackEnvelope(body)); } public ImMessage parseMessageSentCallbackPayload(WebhookCallbackEnvelope envelope) { requireMessageEvent(envelope, "message.sent"); return readPayload(envelope, ImMessage.class); } public ImMessage parseMessageEditedCallbackPayload(String body) { return parseMessageEditedCallbackPayload(parseCallbackEnvelope(body)); } public ImMessage parseMessageEditedCallbackPayload(WebhookCallbackEnvelope envelope) { requireMessageEvent(envelope, "message.edited"); return readPayload(envelope, ImMessage.class); } public ImMessage parseMessageRevokedCallbackPayload(String body) { return parseMessageRevokedCallbackPayload(parseCallbackEnvelope(body)); } public ImMessage parseMessageRevokedCallbackPayload(WebhookCallbackEnvelope envelope) { requireMessageEvent(envelope, "message.revoked"); return readPayload(envelope, ImMessage.class); } public MessageReadCallbackPayload parseMessageReadCallbackPayload(String body) { return parseMessageReadCallbackPayload(parseCallbackEnvelope(body)); } public MessageReadCallbackPayload parseMessageReadCallbackPayload(WebhookCallbackEnvelope envelope) { if (!envelope.isReadReceiptEvent()) { throw new ImSdkException("Callback event is not a message.read event"); } return readPayload(envelope, MessageReadCallbackPayload.class); } public void registerPushToken(String userId, String vendor, String token) { request( "POST", buildUri(pushBaseUrl, "/api/push/register", Map.of( "appKey", appKey, "userId", userId, "vendor", vendor, "token", token )), null, publicHeaders(), new TypeReference>() {} ); } public void sendPush(String userId, String title, String body, String payload) { Map query = new LinkedHashMap<>(); query.put("appKey", appKey); query.put("userId", userId); query.put("title", title); query.put("body", body); if (payload != null) { query.put("payload", payload); } request( "POST", buildUri(pushBaseUrl, "/api/push/send", query), null, publicHeaders(), new TypeReference>() {} ); } public Map pushUserStatus(String userId) { ApiResponse> response = request( "GET", buildUri(pushBaseUrl, "/api/push/admin/user-status", Map.of( "appKey", appKey, "userId", userId )), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public Map pushDeviceLogs(String userId, int page, int size) { ApiResponse> response = request( "GET", buildUri(pushBaseUrl, "/api/push/admin/device-logs", Map.of( "appKey", appKey, "userId", userId, "page", String.valueOf(page), "size", String.valueOf(size) )), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public Map testOfflinePush(String userId, String title, String body, String payload) { Map req = new LinkedHashMap<>(); req.put("appKey", appKey); req.put("userId", userId); req.put("title", title); req.put("body", body); if (payload != null && !payload.isBlank()) { req.put("payload", payload); } ApiResponse> response = request( "POST", buildUri(pushBaseUrl, "/api/push/admin/test-offline", Map.of()), req, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List listFriends() { ApiResponse> response = request( "GET", buildUri("/api/im/friends", appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public FriendLinkView addFriend(String friendId) { Map query = appQuery(); query.put("friendId", friendId); ApiResponse response = request( "POST", buildUri("/api/im/friends", query), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List addFriends(List friendIds) { ApiResponse> response = request( "POST", buildUri("/api/im/friends/batch", appQuery()), new FriendBatchRequest(friendIds), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public void removeFriend(String friendId) { Map query = appQuery(); query.put("friendId", friendId); request( "DELETE", buildUri("/api/im/friends/" + encode(friendId), query), null, authorizedHeaders(), new TypeReference>() {} ); } public void removeFriends(List friendIds) { request( "POST", buildUri("/api/im/friends/batch/remove", appQuery()), new FriendBatchRequest(friendIds), authorizedHeaders(), new TypeReference>() {} ); } public void removeAllFriends() { request( "DELETE", buildUri("/api/im/friends", appQuery()), null, authorizedHeaders(), new TypeReference>() {} ); } public FriendLinkView setFriendGroup(String friendId, String groupName) { Map query = appQuery(); if (groupName != null) { query.put("groupName", groupName); } ApiResponse response = request( "PUT", buildUri("/api/im/friends/" + encode(friendId) + "/group", query), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List listFriendGroups() { ApiResponse> response = request( "GET", buildUri("/api/im/friends/groups", appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List listFriendsByGroup(String groupName) { ApiResponse> response = request( "GET", buildUri("/api/im/friends/groups/" + encode(groupName), appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List listFriendRequests(String direction) { Map query = appQuery(); if (direction != null) { query.put("direction", direction); } ApiResponse> response = request( "GET", buildUri("/api/im/friend-requests", query), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public FriendRequestView sendFriendRequest(String toUserId, String remark) { Map query = appQuery(); query.put("toUserId", toUserId); if (remark != null) { query.put("remark", remark); } ApiResponse response = request( "POST", buildUri("/api/im/friend-requests", query), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public FriendRequestView acceptFriendRequest(String requestId) { ApiResponse response = request( "POST", buildUri("/api/im/friend-requests/" + encode(requestId) + "/accept", appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public FriendRequestView rejectFriendRequest(String requestId) { ApiResponse response = request( "POST", buildUri("/api/im/friend-requests/" + encode(requestId) + "/reject", appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List acceptFriendRequests(List requestIds) { ApiResponse> response = request( "POST", buildUri("/api/im/friend-requests/batch/accept", appQuery()), new RequestBatch(requestIds), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List rejectFriendRequests(List requestIds) { ApiResponse> response = request( "POST", buildUri("/api/im/friend-requests/batch/reject", appQuery()), new RequestBatch(requestIds), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List listBlacklist() { ApiResponse> response = request( "GET", buildUri("/api/im/blacklist", appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public BlacklistView addBlacklist(String blockedUserId) { Map query = appQuery(); query.put("blockedUserId", blockedUserId); ApiResponse response = request( "POST", buildUri("/api/im/blacklist", query), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public void removeBlacklist(String blockedUserId) { Map query = appQuery(); query.put("blockedUserId", blockedUserId); request( "DELETE", buildUri("/api/im/blacklist", query), null, authorizedHeaders(), new TypeReference>() {} ); } public BlacklistCheckResult checkBlacklist(String targetUserId) { Map query = appQuery(); query.put("targetUserId", targetUserId); ApiResponse response = request( "GET", buildUri("/api/im/blacklist/check", query), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List listGroups() { ApiResponse> response = request( "GET", buildUri("/api/im/groups", appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List listPublicGroups(String keyword) { Map query = appQuery(); if (keyword != null) { query.put("keyword", keyword); } ApiResponse> response = request( "GET", buildUri("/api/im/groups/public", query), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List searchGroups(String keyword, int size) { ApiResponse> response = request( "GET", buildUri("/api/im/groups/search", queryWithSize("keyword", keyword, size)), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView getGroup(String groupId) { ApiResponse response = request( "GET", buildUri("/api/im/groups/" + encode(groupId), appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List listGroupMembers(String groupId) { ApiResponse> response = request( "GET", buildUri("/api/im/groups/" + encode(groupId) + "/members", appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List searchGroupMembers(String groupId, String keyword, int size) { ApiResponse> response = request( "GET", buildUri("/api/im/groups/" + encode(groupId) + "/members/search", queryWithSize("keyword", keyword, size)), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView createGroup(String name, List memberIds, String groupType) { ApiResponse response = request( "POST", buildUri("/api/im/groups", appQuery()), new CreateGroupRequest(name, memberIds, groupType), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView updateGroup(String groupId, String name, String announcement) { ApiResponse response = request( "PUT", buildUri("/api/im/groups/" + encode(groupId), appQuery()), new UpdateGroupRequest(name, announcement), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView addGroupMember(String groupId, String userId) { ApiResponse response = request( "POST", buildUri("/api/im/groups/" + encode(groupId) + "/members", appQuery()), new MemberRequest(userId), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView addGroupMembers(String groupId, List userIds) { ApiResponse response = request( "POST", buildUri("/api/im/groups/" + encode(groupId) + "/members/batch", appQuery()), new MemberBatchRequest(userIds), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView removeGroupMember(String groupId, String targetUserId) { ApiResponse response = request( "DELETE", buildUri("/api/im/groups/" + encode(groupId) + "/members/" + encode(targetUserId), appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView removeGroupMembers(String groupId, List userIds) { ApiResponse response = request( "POST", buildUri("/api/im/groups/" + encode(groupId) + "/members/batch/remove", appQuery()), new MemberBatchRequest(userIds), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView setGroupRole(String groupId, String userId, String role) { ApiResponse response = request( "POST", buildUri("/api/im/groups/" + encode(groupId) + "/roles", appQuery()), new SetRoleRequest(userId, role), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView transferGroupOwner(String groupId, String newOwnerId) { ApiResponse response = request( "POST", buildUri("/api/im/groups/" + encode(groupId) + "/owner", appQuery()), new TransferOwnerRequest(newOwnerId), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public void leaveGroup(String groupId) { request( "DELETE", buildUri("/api/im/groups/" + encode(groupId) + "/members/me", appQuery()), null, authorizedHeaders(), new TypeReference>() {} ); } public GroupView updateGroupAttributes(String groupId, Map attributes) { ApiResponse response = request( "PUT", buildUri("/api/im/groups/" + encode(groupId) + "/attributes", appQuery()), attributes == null ? Map.of() : attributes, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView removeGroupAttributes(String groupId, List keys) { ApiResponse response = request( "POST", buildUri("/api/im/groups/" + encode(groupId) + "/attributes/delete", appQuery()), new AttributeKeysRequest(keys), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView muteGroupMember(String groupId, String userId, long minutes) { ApiResponse response = request( "POST", buildUri("/api/im/groups/" + encode(groupId) + "/mute", appQuery()), new MuteMemberRequest(userId, minutes), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public void dismissGroup(String groupId) { request( "DELETE", buildUri("/api/im/groups/" + encode(groupId), appQuery()), null, authorizedHeaders(), new TypeReference>() {} ); } public GroupJoinRequestView sendGroupJoinRequest(String groupId, String remark) { Map query = appQuery(); if (remark != null) { query.put("remark", remark); } ApiResponse response = request( "POST", buildUri("/api/im/groups/" + encode(groupId) + "/join-requests", query), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List listGroupJoinRequests(String groupId) { ApiResponse> response = request( "GET", buildUri("/api/im/groups/" + encode(groupId) + "/join-requests", appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupJoinRequestView acceptGroupJoinRequest(String groupId, String requestId) { ApiResponse response = request( "POST", buildUri("/api/im/groups/" + encode(groupId) + "/join-requests/" + encode(requestId) + "/accept", appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupJoinRequestView rejectGroupJoinRequest(String groupId, String requestId) { ApiResponse response = request( "POST", buildUri("/api/im/groups/" + encode(groupId) + "/join-requests/" + encode(requestId) + "/reject", appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List acceptGroupJoinRequests(String groupId, List requestIds) { ApiResponse> response = request( "POST", buildUri("/api/im/groups/" + encode(groupId) + "/join-requests/batch/accept", appQuery()), new RequestBatch(requestIds), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List rejectGroupJoinRequests(String groupId, List requestIds) { ApiResponse> response = request( "POST", buildUri("/api/im/groups/" + encode(groupId) + "/join-requests/batch/reject", appQuery()), new RequestBatch(requestIds), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public Map queryUserState(List userIds) { Map query = new LinkedHashMap<>(); query.put("userIds", String.join(",", userIds)); ApiResponse> response = request( "GET", buildUri("/api/im/admin/users/state", query), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public void kickUsers(String appKey, List userIds) { Map query = new LinkedHashMap<>(); query.put("appKey", appKey); request( "POST", buildUri("/api/im/admin/users/kick", query), Map.of("userIds", userIds), authorizedHeaders(), new TypeReference>() {} ); } public List batchSendMessage(String appKey, List toIds, String msgType, String content) { Map query = new LinkedHashMap<>(); query.put("appKey", appKey); ApiResponse> response = request( "POST", buildUri("/api/im/admin/messages/batch-send", query), Map.of("toIds", toIds, "msgType", msgType, "content", content), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public void adminSetMsgRead(String appKey, String userId) { Map query = new LinkedHashMap<>(); query.put("appKey", appKey); request( "POST", buildUri("/api/im/admin/messages/read", query), Map.of("userId", userId), authorizedHeaders(), new TypeReference>() {} ); } public List importMessages(String appKey, List requests) { Map query = new LinkedHashMap<>(); query.put("appKey", appKey); ApiResponse> response = request( "POST", buildUri("/api/im/admin/messages/import", query), requests, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List checkFriends(String appKey, List friendIds) { Map query = new LinkedHashMap<>(); query.put("appKey", appKey); ApiResponse> response = request( "POST", buildUri("/api/im/friends/check", query), Map.of("friendIds", friendIds), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView modifyGroupMemberInfo(String groupId, String userId, String nickName, String role) { Map body = new LinkedHashMap<>(); if (nickName != null) { body.put("nickName", nickName); } if (role != null) { body.put("role", role); } ApiResponse response = request( "PUT", buildUri("/api/im/groups/" + encode(groupId) + "/members/" + encode(userId) + "/info", appQuery()), body, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView adminTransferGroupOwner(String groupId, String newOwnerId) { ApiResponse response = request( "POST", buildUri("/api/im/admin/groups/" + encode(groupId) + "/owner", appQuery()), new TransferOwnerRequest(newOwnerId), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView adminUpdateGroupAttributes(String groupId, Map attributes) { ApiResponse response = request( "PUT", buildUri("/api/im/admin/groups/" + encode(groupId) + "/attributes", appQuery()), attributes == null ? Map.of() : attributes, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public GroupView adminRemoveGroupAttributes(String groupId, List keys) { ApiResponse response = request( "POST", buildUri("/api/im/admin/groups/" + encode(groupId) + "/attributes/delete", appQuery()), new AttributeKeysRequest(keys), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List adminGroupReadReceipts(String groupId, List messageIds) { ApiResponse> response = request( "POST", buildUri("/api/im/admin/groups/" + encode(groupId) + "/read-receipts", appQuery()), new GroupReadReceiptRequest(messageIds), authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public void setConversationPinned(String targetId, String chatType, boolean pinned) { Map query = appQuery(); query.put("chatType", chatType); query.put("pinned", String.valueOf(pinned)); request( "PUT", buildUri("/api/im/conversations/" + encode(targetId) + "/pinned", query), null, authorizedHeaders(), new TypeReference>() {} ); } public void setConversationMuted(String targetId, String chatType, boolean muted) { Map query = appQuery(); query.put("chatType", chatType); query.put("muted", String.valueOf(muted)); request( "PUT", buildUri("/api/im/conversations/" + encode(targetId) + "/muted", query), null, authorizedHeaders(), new TypeReference>() {} ); } public void markRead(String targetId, String chatType) { Map query = appQuery(); query.put("chatType", chatType); request( "PUT", buildUri("/api/im/conversations/" + encode(targetId) + "/read", query), null, authorizedHeaders(), new TypeReference>() {} ); } public void setDraft(String targetId, String chatType, String draft) { Map query = appQuery(); query.put("chatType", chatType); if (draft != null) { query.put("draft", draft); } request( "PUT", buildUri("/api/im/conversations/" + encode(targetId) + "/draft", query), null, authorizedHeaders(), new TypeReference>() {} ); } public void setConversationHidden(String targetId, String chatType, boolean hidden) { Map query = appQuery(); query.put("chatType", chatType); query.put("hidden", String.valueOf(hidden)); request( "PUT", buildUri("/api/im/conversations/" + encode(targetId) + "/hidden", query), null, authorizedHeaders(), new TypeReference>() {} ); } public void setConversationGroup(String targetId, String chatType, String groupName) { Map query = appQuery(); query.put("chatType", chatType); if (groupName != null) { query.put("groupName", groupName); } request( "PUT", buildUri("/api/im/conversations/" + encode(targetId) + "/group", query), null, authorizedHeaders(), new TypeReference>() {} ); } public List listConversationGroups() { ApiResponse> response = request( "GET", buildUri("/api/im/conversation-groups", appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public List listConversationGroupItems(String groupName) { ApiResponse> response = request( "GET", buildUri("/api/im/conversation-groups/" + encode(groupName), appQuery()), null, authorizedHeaders(), new TypeReference<>() {} ); return response.data(); } public void deleteConversation(String targetId, String chatType) { Map query = appQuery(); query.put("chatType", chatType); request( "DELETE", buildUri("/api/im/conversations/" + encode(targetId), query), null, authorizedHeaders(), new TypeReference>() {} ); } private static String sha256Hex(String value) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(normalize(value).getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(hash); } catch (Exception e) { throw new ImSdkException("Failed to hash callback body", e); } } private static String hmacSha256Hex(String secret, String payload) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(hash); } catch (Exception e) { throw new ImSdkException("Failed to verify callback signature", e); } } private static String normalize(String value) { return value == null ? "" : value; } private T readPayload(WebhookCallbackEnvelope envelope, Class payloadType) { try { return objectMapper.treeToValue(envelope.payload(), payloadType); } catch (JsonProcessingException e) { throw new ImSdkException("Failed to parse callback payload", e); } } private void requireMessageEvent(WebhookCallbackEnvelope envelope, String event) { if (!envelope.isMessageEvent() || !envelope.isEvent(event)) { throw new ImSdkException("Callback event is not a " + event + " event"); } } private static String text(JsonNode node, String field) { JsonNode value = node == null ? null : node.get(field); return value == null || value.isNull() ? null : value.asText(); } private static long longValue(JsonNode node, String field) { JsonNode value = node == null ? null : node.get(field); if (value == null || value.isNull()) { return 0L; } return value.asLong(); } private Map authorizedHeaders() { String token = bearerTokenSupplier == null ? null : bearerTokenSupplier.get(); if (token == null || token.isBlank()) { throw new ImSdkException("Bearer token is required for this call"); } return Map.of("Authorization", "Bearer " + token); } private Map publicHeaders() { return Map.of(); } private ApiResponse request( String method, URI uri, Object body, Map headers, TypeReference> responseType ) { try { HttpRequest.Builder builder = HttpRequest.newBuilder(uri) .header("Content-Type", "application/json"); String[] flatHeaders = flatten(headers); if (flatHeaders.length > 0) builder = builder.headers(flatHeaders); if (body != null) { builder.method(method, HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))); } else if ("GET".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method)) { builder.method(method, HttpRequest.BodyPublishers.noBody()); } else { builder.method(method, HttpRequest.BodyPublishers.noBody()); } HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) { throw new ImSdkException("HTTP " + response.statusCode() + ": " + response.body()); } ApiResponse apiResponse = objectMapper.readValue(response.body(), responseType); if (apiResponse.code() != 200) { throw new ImSdkException(apiResponse.message()); } return apiResponse; } catch (IOException e) { throw new ImSdkException("Request failed: " + e.getMessage(), e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new ImSdkException("Request interrupted", e); } } private ApiResponse multipartRequest( String method, URI uri, Map formFields, String fileFieldName, Path file, Map headers, TypeReference> responseType ) { String boundary = "----XuqmBoundary" + UUID.randomUUID().toString().replace("-", ""); try { HttpRequest.BodyPublisher bodyPublisher = ofMultipartData(boundary, formFields, fileFieldName, file); Map mergedHeaders = new LinkedHashMap<>(headers); mergedHeaders.put("Content-Type", "multipart/form-data; boundary=" + boundary); HttpRequest.Builder builder = HttpRequest.newBuilder(uri) .headers(flatten(mergedHeaders)) .method(method, bodyPublisher); HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) { throw new ImSdkException("HTTP " + response.statusCode() + ": " + response.body()); } ApiResponse apiResponse = objectMapper.readValue(response.body(), responseType); if (apiResponse.code() != 200) { throw new ImSdkException(apiResponse.message()); } return apiResponse; } catch (IOException e) { throw new ImSdkException("Request failed: " + e.getMessage(), e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new ImSdkException("Request interrupted", e); } } private ApiResponse multipartRequest( String method, URI uri, Map formFields, Map files, Map headers, TypeReference> responseType ) { String boundary = "----XuqmBoundary" + UUID.randomUUID().toString().replace("-", ""); try { HttpRequest.BodyPublisher bodyPublisher = ofMultipartData(boundary, formFields, files); Map mergedHeaders = new LinkedHashMap<>(headers); mergedHeaders.put("Content-Type", "multipart/form-data; boundary=" + boundary); HttpRequest.Builder builder = HttpRequest.newBuilder(uri) .headers(flatten(mergedHeaders)) .method(method, bodyPublisher); HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) { throw new ImSdkException("HTTP " + response.statusCode() + ": " + response.body()); } ApiResponse apiResponse = objectMapper.readValue(response.body(), responseType); if (apiResponse.code() != 200) { throw new ImSdkException(apiResponse.message()); } return apiResponse; } catch (IOException e) { throw new ImSdkException("Request failed: " + e.getMessage(), e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new ImSdkException("Request interrupted", e); } } private HttpRequest.BodyPublisher ofMultipartData( String boundary, Map formFields, String fileFieldName, Path file ) throws IOException { var byteArrays = new ArrayList(); Charset charset = StandardCharsets.UTF_8; for (Map.Entry entry : formFields.entrySet()) { byteArrays.add(("--" + boundary + "\r\n").getBytes(charset)); byteArrays.add(("Content-Disposition: form-data; name=\"" + entry.getKey() + "\"\r\n\r\n").getBytes(charset)); byteArrays.add(entry.getValue().getBytes(charset)); byteArrays.add("\r\n".getBytes(charset)); } byteArrays.add(("--" + boundary + "\r\n").getBytes(charset)); byteArrays.add(("Content-Disposition: form-data; name=\"" + fileFieldName + "\"; filename=\"" + file.getFileName() + "\"\r\n").getBytes(charset)); byteArrays.add(("Content-Type: application/octet-stream\r\n\r\n").getBytes(charset)); byteArrays.add(Files.readAllBytes(file)); byteArrays.add("\r\n".getBytes(charset)); byteArrays.add(("--" + boundary + "--\r\n").getBytes(charset)); return HttpRequest.BodyPublishers.ofByteArrays(byteArrays); } private HttpRequest.BodyPublisher ofMultipartData( String boundary, Map formFields, Map files ) throws IOException { var byteArrays = new ArrayList(); Charset charset = StandardCharsets.UTF_8; for (Map.Entry entry : formFields.entrySet()) { byteArrays.add(("--" + boundary + "\r\n").getBytes(charset)); byteArrays.add(("Content-Disposition: form-data; name=\"" + entry.getKey() + "\"\r\n\r\n").getBytes(charset)); byteArrays.add(entry.getValue().getBytes(charset)); byteArrays.add("\r\n".getBytes(charset)); } for (Map.Entry entry : files.entrySet()) { Path file = entry.getValue(); byteArrays.add(("--" + boundary + "\r\n").getBytes(charset)); byteArrays.add(("Content-Disposition: form-data; name=\"" + entry.getKey() + "\"; filename=\"" + file.getFileName() + "\"\r\n").getBytes(charset)); byteArrays.add(("Content-Type: application/octet-stream\r\n\r\n").getBytes(charset)); byteArrays.add(Files.readAllBytes(file)); byteArrays.add("\r\n".getBytes(charset)); } byteArrays.add(("--" + boundary + "--\r\n").getBytes(charset)); return HttpRequest.BodyPublishers.ofByteArrays(byteArrays); } private URI buildUri(String path, Map query) { StringBuilder builder = new StringBuilder(baseUrl).append(path); if (query != null && !query.isEmpty()) { builder.append('?'); boolean first = true; for (Map.Entry entry : query.entrySet()) { String value = entry.getValue(); if (value == null) { continue; } if (!first) { builder.append('&'); } builder.append(encode(entry.getKey())).append('=').append(encode(value)); first = false; } } return URI.create(builder.toString()); } private URI buildUri(String base, String path, Map query) { StringBuilder builder = new StringBuilder(trimTrailingSlash(base)).append(path); if (query != null && !query.isEmpty()) { builder.append('?'); boolean first = true; for (Map.Entry entry : query.entrySet()) { String value = entry.getValue(); if (value == null) { continue; } if (!first) { builder.append('&'); } builder.append(encode(entry.getKey())).append('=').append(encode(value)); first = false; } } return URI.create(builder.toString()); } private Map appQuery() { Map query = new LinkedHashMap<>(); query.put("appKey", appKey); return query; } private Map queryWithPage(int page, int size) { Map query = appQuery(); query.put("page", String.valueOf(page)); query.put("size", String.valueOf(size)); return query; } private Map queryWithSize(String key, String value, int size) { Map query = appQuery(); query.put(key, value); query.put("size", String.valueOf(size)); return query; } private Map queryParams(String msgType, String keyword, LocalDateTime startTime, LocalDateTime endTime, int page, int size) { Map query = new LinkedHashMap<>(); query.put("appKey", appKey); if (msgType != null && !msgType.isBlank()) { query.put("msgType", msgType); } if (keyword != null && !keyword.isBlank()) { query.put("keyword", keyword); } if (startTime != null) { query.put("startTime", startTime.toInstant(ZoneOffset.UTC).toString()); } if (endTime != null) { query.put("endTime", endTime.toInstant(ZoneOffset.UTC).toString()); } query.put("page", String.valueOf(page)); query.put("size", String.valueOf(size)); return query; } private Map messageSearchQuery( String chatType, String msgType, String keyword, LocalDateTime startTime, LocalDateTime endTime, int page, int size ) { Map query = new LinkedHashMap<>(); query.put("appKey", appKey); if (chatType != null && !chatType.isBlank()) { query.put("chatType", chatType); } if (msgType != null && !msgType.isBlank()) { query.put("msgType", msgType); } if (keyword != null && !keyword.isBlank()) { query.put("keyword", keyword); } if (startTime != null) { query.put("startTime", startTime.toInstant(ZoneOffset.UTC).toString()); } if (endTime != null) { query.put("endTime", endTime.toInstant(ZoneOffset.UTC).toString()); } query.put("page", String.valueOf(page)); query.put("size", String.valueOf(size)); return query; } private String[] flatten(Map headers) { if (headers == null || headers.isEmpty()) return new String[0]; String[] pairs = new String[headers.size() * 2]; int index = 0; for (Map.Entry entry : headers.entrySet()) { pairs[index++] = entry.getKey(); pairs[index++] = entry.getValue(); } return pairs; } private String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } private String trimTrailingSlash(String value) { return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; } private static final String DEFAULT_BASE_URL = "https://dev.xuqinmin.com"; public static final class Builder { private String baseUrl = DEFAULT_BASE_URL; private String pushBaseUrl; private String appKey; private String appSecret; private Supplier bearerTokenSupplier; private Builder() {} public Builder baseUrl(String baseUrl) { this.baseUrl = baseUrl; return this; } public Builder pushBaseUrl(String pushBaseUrl) { this.pushBaseUrl = pushBaseUrl; return this; } public Builder appKey(String appKey) { this.appKey = appKey; return this; } public Builder appSecret(String appSecret) { this.appSecret = appSecret; return this; } public Builder bearerTokenSupplier(Supplier bearerTokenSupplier) { this.bearerTokenSupplier = bearerTokenSupplier; return this; } public XuqmImServerSdk build() { Objects.requireNonNull(this.appKey, "appKey is required"); Objects.requireNonNull(this.appSecret, "appSecret is required"); return new XuqmImServerSdk(this); } } public record LoginResponse(String token) {} public record UserSigClaims(String appKey, String userId, long issuedAt, long expiresAt, String userBuf, String userSig) {} public record ConversationView( String targetId, String chatType, String lastMsgContent, String lastMsgType, Long lastMsgTime, int unreadCount, boolean isMuted, boolean isPinned, String conversationGroup ) {} public record ConversationGroupItem( String targetId, String chatType, String groupName ) {} public record AccountView( String id, String appKey, String userId, String nickname, String gender, String avatar, boolean admin, String status, LocalDateTime createdAt ) {} public record ImportAccountRequest( String userId, String nickname, String avatar, String gender, String status, Boolean admin ) {} public record FriendLinkView( Long id, String appKey, String userId, String friendId, String friendGroup, Instant createdAt ) {} public record FriendBatchRequest(List friendIds) {} public record FriendRequestView( String id, String appKey, String fromUserId, String toUserId, String remark, String status, LocalDateTime createdAt, LocalDateTime reviewedAt ) {} public record BlacklistView( String id, String appKey, String userId, String blockedUserId, LocalDateTime createdAt ) {} public record BlacklistCheckResult( String targetUserId, boolean blockedByMe, boolean blockedByTarget, boolean eitherBlocked ) {} public record GroupView( String id, String appKey, String name, String groupType, String creatorId, String memberIds, String adminIds, String announcement, String memberInfo, String extAttributes, LocalDateTime createdAt ) { public List memberIdList() { return parseJsonStringList(memberIds); } public List adminIdList() { return parseJsonStringList(adminIds); } } public record GroupReadReceiptSummary( String messageId, String groupId, int memberCount, int readCount, int unreadCount ) {} public record GroupJoinRequestView( String id, String appKey, String groupId, String requesterId, String remark, String status, LocalDateTime createdAt, LocalDateTime reviewedAt ) {} public record WebhookCallbackEnvelope( String callbackId, String callbackType, String callbackEvent, long requestTime, JsonNode payload, String signature, String appKey ) { public boolean isType(String type) { return type != null && callbackType != null && callbackType.equalsIgnoreCase(type); } public boolean isEvent(String event) { return event != null && callbackEvent != null && callbackEvent.equalsIgnoreCase(event); } public boolean isMessageEvent() { return isType("message"); } public boolean isMessageSentEvent() { return isEvent("message.sent"); } public boolean isMessageEditedEvent() { return isEvent("message.edited"); } public boolean isMessageRevokedEvent() { return isEvent("message.revoked"); } public boolean isReadReceiptEvent() { return isEvent("message.read"); } } public record HistoryQuery( String msgType, String keyword, LocalDateTime startTime, LocalDateTime endTime, Integer page, Integer size ) { public HistoryQuery() { this(null, null, null, null, null, null); } } public record MessageSearchQuery( String chatType, String msgType, String keyword, LocalDateTime startTime, LocalDateTime endTime, Integer page, Integer size ) { public MessageSearchQuery() { this(null, null, null, null, null, null, null); } } public record PageResult( List content, long totalElements, int totalPages, int size, int number, int numberOfElements, boolean first, boolean last, boolean empty ) {} public record ImMessage( String id, String appKey, String fromUserId, String toId, String chatType, String msgType, String content, String status, String mentionedUserIds, Integer groupReadCount, Long createdAt, Long editedAt ) {} public record SendMessageRequest( String messageId, String toId, String chatType, String msgType, String content, String mentionedUserIds ) {} public record WebhookConfigRequest( String url, String secret, Boolean enabled ) {} public record WebhookConfigView( String id, String appKey, String url, String secret, boolean enabled, LocalDateTime createdAt ) {} public record KeywordFilterRequest( String pattern, String replacement, String action, Boolean enabled ) {} public record AppVersionUploadRequest( String appKey, String platform, String versionName, int versionCode, String changeLog, boolean forceUpdate ) {} public record RnBundleUploadRequest( String appKey, String moduleId, String platform, String version, String minCommonVersion, String note ) {} public record MessageReadCallbackPayload( String appKey, String readerId, String peerId, String groupId, String chatType, long readAt, List messageIds ) {} public record CreateGroupRequest( String name, List memberIds, String groupType ) {} public record UpdateGroupRequest( String name, String announcement ) {} public record MemberRequest(String userId) {} public record MemberBatchRequest(List userIds) {} public record SetRoleRequest(String userId, String role) {} public record TransferOwnerRequest(String newOwnerId) {} public record AttributeKeysRequest(List keys) {} public record MuteMemberRequest(String userId, long minutes) {} public record GroupReadReceiptRequest(List messageIds) {} public record RequestBatch(List requestIds) {} public record FriendCheckResult(String userId, boolean isFriend) {} public record ImportMessageRequest( String messageId, String fromUserId, String toId, String chatType, String msgType, String content, String status, String createdAt ) {} public static final class ImSdkException extends RuntimeException { public ImSdkException(String message) { super(message); } public ImSdkException(String message, Throwable cause) { super(message, cause); } } private byte[] writeJson(Map payload) { try { return objectMapper.writeValueAsBytes(payload); } catch (JsonProcessingException e) { throw new IllegalStateException("Failed to serialize UserSig payload", e); } } private JsonNode readJson(byte[] bytes) { try { return objectMapper.readTree(bytes); } catch (IOException e) { throw new IllegalArgumentException("Invalid UserSig payload", e); } } private static byte[] hmac(String secret, String value) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); return mac.doFinal(value.getBytes(StandardCharsets.UTF_8)); } catch (Exception e) { throw new IllegalStateException("Failed to sign UserSig", e); } } private static String base64Url(String value) { return Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)); } private static String base64Url(byte[] bytes) { return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } private static byte[] base64UrlDecode(String value) { return Base64.getUrlDecoder().decode(value); } private static List parseJsonStringList(String value) { if (value == null || value.isBlank()) { return List.of(); } String trimmed = value.trim(); if (!trimmed.startsWith("[")) { return trimmed.isEmpty() ? List.of() : List.of(trimmed.split(",")); } try { ObjectMapper mapper = new ObjectMapper(); return mapper.readValue(trimmed, new TypeReference>() {}); } catch (IOException e) { return new ArrayList<>(List.of(trimmed)); } } }