Java / Spring Boot SDK
The Java server SDK is published on Maven Central, currently at 1.0.0-alpha.3 (MIT license), with 15 services and 198 OpenAPI operations. The core library is built on Java 17 and Jackson; the Spring Boot Starter adds configuration property binding, a replaceable DRClient bean and an optional callback receiver. The Maven groupId is cn.deeprespond, and the Java package is com.deeprespond.im.
Install with Maven
Add the dependency to your project. With Spring Boot, add the Starter; without it, add the core library im-server-sdk:
<dependency>
<groupId>cn.deeprespond</groupId>
<artifactId>im-spring-boot-starter</artifactId>
<version>1.0.0-alpha.3</version>
</dependency>It needs JDK 17 or later and downloads straight from Maven Central, with no publishing token and no local install first. For building from source and running the demo, see the instructions in the repository.
Configure
deeprespond:
im:
base-url: ${IM_BASE_URL}
org-name: ${IM_ORG_NAME}
app-name: ${IM_APP_NAME}
client-id: ${IM_CLIENT_ID}
client-secret: ${IM_CLIENT_SECRET}Inject com.deeprespond.im.DRClient through the constructor where you need it, and call APIs with im.users(), im.messages() and so on. The Client Secret and App Token always stay on the back end.
var result = im.messages().send(Params.of("from", "alice")
.set("to_user", "bob").set("type", "text").set("body", Map.of("text", "你好")));
String messageId = result.data().messageId();Import com.deeprespond.im.Params and java.util.Map. A response's data is a record type, and raw keeps every field the server returned; don't log responses containing sensitive information.
AI streaming replies
In AI conversations, after a user messages a bot you get an ai.message_received callback, then start a reply with client.ai().startStream(...) and write it as it's generated. The returned ReplyStream can be written from several threads at once, and a background thread handles numbering and batching deltas (sending a chunk every 20 milliseconds or 4 KB), switching between continuous and batch writes, and resending on failure; after three failed writes in a row it ends with fail("transport", ""). Manage bots with client.ai().createBot(Params) and related methods.
Callback handlers should return quickly, with the model call in a thread pool. Use the event ID with withClientStreamId so a redelivered callback doesn't start a second reply:
handler.onAIMessage(event -> {
JsonNode data = event.path("data");
ReplyStream stream = client.ai().startStream(
StartStreamInput.of(data.path("bot").asText(), data.path("from").asText())
.withReplyToSeq(data.path("message").path("seq").asLong())
.withClientStreamId(event.path("id").asText()));
executor.submit(() -> reply(stream, data.path("message").path("body").path("text").asText()));
});withReplace(true) first ends any reply being generated in the conversation; withBatchOnly(true) uses only batch writes.
pipeOpenAI, pipeOpenAIResponses, pipeAnthropic and pipeGemini read the model's stream to the end and then finish automatically; if the model returns an error, they end with fail("model_error", description) and throw. The argument can be a Flow.Publisher, Iterable, Iterator or java.util.stream.Stream (with chunks as official SDK objects, JsonNode, Map or SSE text), or an SSE InputStream, Reader or HttpResponse. The four conversions follow the same rules as the Go SDK.
void reply(ReplyStream stream, String question) {
try (stream; var response = openai.chat().completions().createStreaming(ChatCompletionCreateParams.builder()
.model("gpt-4.1").addUserMessage(question).build())) {
stream.pipeOpenAI(response.stream());
}
}Writing manually:
stream.writeReasoning("先查询天气。");
stream.writeTool("call_1", "weather", "查询天气", ReplyStream.TOOL_RUNNING);
stream.writeTool("call_1", "weather", "查询天气", ReplyStream.TOOL_DONE);
stream.writeText("明天上海**多云**,最高 23℃。");
stream.writeImage("https://cdn.example.com/a.png", 800, 600, "天气图");
AIStream result = stream.finish(new ReplyStream.FinishOptions("stop", "gpt-4.1", 120L, 380L));write*only buffers in memory without waiting on the network;flush()sends immediately and waits for confirmation;finishconfirms all content first, then ends.- When the model call fails, call
stream.fail("model_error", "The model is temporarily unavailable"); content generated so far is kept, and the user sees "Generation interrupted". close()(or try-with-resources) ends the stream withfail("canceled", "")if it hasn't finished.- When the user taps stop, the reply times out, or it's replaced by a new reply,
stream.cancellation()(aCompletableFuture<DRException>) completes andisCancelled()is true; later writes throwstream_closed, andpipe*stops reading and closes the model's stream.CallbackHandlerstops the matching stream in this process automatically whenai.stream_stoppedarrives; if you handle callbacks yourself, callclient.ai().streamStopped(streamId).
Game rooms
Game rooms give multiplayer games a real-time match channel. Your server uses im.game() to create rooms, issue join tickets to players and close rooms when matches end; players join and play through the client SDK. Here is what your server does for one match (im is the injected DRClient):
/** 匹配成功后建房,给每个玩家签一张加入票据。 */
Map<String, String> startMatch(String matchId, List<String> players) {
try {
// 房间 ID 用你的对局 ID:重试或重复调用都只有一个房间
im.game().createRoom(matchId, Params.of("max_players", players.size())
.set("start_mode", "all_ready").set("ext", Map.of("mode", "ranked")));
} catch (DRException e) {
if ("game_capacity_unavailable".equals(e.reason())) throw new IllegalStateException("游戏服务繁忙,请稍后再试", e);
if ("open_rooms".equals(e.reason())) throw new IllegalStateException("同时进行的对局太多", e);
throw e;
}
List<Map<String, Object>> users = new ArrayList<>();
for (int seat = 0; seat < players.size(); seat++) users.add(Map.of("username", players.get(seat), "seat", seat));
// 不会自动重试;超时后直接再签一次,旧票据随之作废
var tickets = im.game().issueTickets(matchId, Params.of("users", users)).data().tickets();
Map<String, String> byUser = new HashMap<>();
for (var t : tickets) byUser.put(t.username(), t.ticket()); // 经你的业务接口分别交给每个玩家,不要写进日志
return byUser;
}
/** 对局结束时带上结算结果结束房间。 */
void finishMatch(String matchId, String winner) {
var res = im.game().closeRoom(matchId, Params.of("reason", "对局结束").set("result", Map.of("winner", winner)));
// 202:这次开始了结束;200:房间之前已在结束(例如超时自动结束),这次的原因和结果不生效
if (res.status() == 200) System.out.println(matchId + " 之前已在结束,这次的结果不生效");
}
/** 把作弊的玩家移出房间;返回 false 表示他已经不在房间里。 */
boolean kickCheater(String matchId, String username) {
return im.game().removeMember(matchId, username, Params.of("reason", "作弊")).status() == 202;
}Import com.deeprespond.im.DRException, com.deeprespond.im.Params and java.util.*.
| To do | Method | Notes |
|---|---|---|
| Create a room | createRoom(roomId, Params) | Use your match ID as the room ID. Calling again with the same parameters returns the same room (status 200; a new room is 201), and network errors are retried automatically |
| Issue join tickets | issueTickets(roomId, Params) | 1–10 players per call. Not retried automatically: after a timeout just issue again, which revokes the user's previous unused ticket. The raw ticket is returned only once and never appears in toString(); don't log it |
| Start manually | startRoom(roomId, Params.empty()) | Only for rooms whose start mode is manual |
| Close a room | closeRoom(roomId, Params) | The result is passed as-is to the players and to the game_room.closed callback. Status 202 means this call started closing; 200 means the room was already closing and this call's reason and result don't apply |
| Remove a player | removeMember(roomId, username, Params) | The SDK generates operation_id, keeps it across retries and doesn't modify your Params. Status 200 with a null member() means the player isn't in the room |
| Query | getRoom, listRooms, listMembers, listRegions | All take Params (Params.empty() when there are none); page listRooms with cursor |
- Every method also has an overload with
RequestOptions. Results areResult<T>:status()is the HTTP status code,data()is a record, andraw()keeps all fields. - Common errors throw
DRException: when the region has no free game node,code()isunavailableandreason()isgame_capacity_unavailable— retry later or give fallback regions withregionswhen creating the room;limit_exceeded(open_rooms) when the number of open rooms hits the limit. See game room errors for the full list. extcan be up to 2KB andresultup to 4KB; for limits and billing see Console, billing and limits.- Request and response fields are in Game rooms: server API. Game room methods are available from 1.0.0-alpha.3.
Game room callbacks
CallbackHandler.on registers by event name; the event you receive is a JsonNode with the fields under data. Most games only need game_room.closed:
var handler = new CallbackHandler(id -> secrets.getOrDefault(id, List.of()), new MemoryCallbackStore());
handler.on("game_room.closed", event -> {
JsonNode data = event.path("data");
// result 是结束房间时给的结算结果;超时、全员离开等自动结束时为 null
saveMatch(data.path("room_id").asText(), data.path("close_reason").asText(), data.path("result"));
for (JsonNode m : data.path("members")) addPlayMinutes(m.path("username").asText(), m.path("billable_minutes").asLong());
});
handler.on("game_room.member_left", event -> {
JsonNode data = event.path("data");
System.out.printf("%s 离开了 %s:%s%n", data.path("username").asText(), data.path("room_id").asText(), data.path("reason").asText());
});- 7 events:
game_room.created,started,member_joined,member_connected,member_disconnected,member_leftandclosed; fields are in game room callback events.member_connectedandmember_disconnectedare frequent, so subscribe only if needed. - Events can arrive more than once or out of order: the handler deduplicates by event ID; in your own logic use
data.version(the room version) to tell newer from older. - When the room closes automatically (timeout, everyone left)
resultisnull;billable_minutesis this match's minutes per player, rounded up, while invoices sum per day and then round.
What it does and doesn't cover
Already provided:
- The App Token is cached in the process, and only fetched once when several threads need a new one at the same time.
- Automatic retries by API category, an optional total time limit per call, and idempotency keys (
client_msg_id) for sending messages. - Cursor paging; single and multipart file uploads, and streaming downloads.
- Callbacks verify signatures over the raw request body, with secret rotation, replay prevention, event de-duplication and time-limit checks for sync callbacks.
Not there yet:
- Request parameters all use the generic
Paramsto express "not sent", "sent as null" and "sent with a value", rather than a dedicated parameter type per API. - There's no store for sharing the App Token across processes (TokenStore), no built-in Redis, no full rate limiting grouped by reason, no concurrent part uploads, and expired download addresses aren't fetched again automatically.
- When several instances receive callbacks, you must implement a shared
CallbackStoreyourself. - Spring Boot 2 and 4, WebFlux and GraalVM native images haven't been tested.
For full parameters, methods and scope, see sdk/server/java/README.md in the repository and the shared OpenAPI definition. To run the example, see the Java server example.
