Skip to content

Commit d7ee8aa

Browse files
feat(chat): add sendWhisper (direct messages) and fix DirectChat decoder labels
Add sendWhisper(targetId, message) mirroring sendChat across SOOPChatClient, SOOPConnection and WebSocketManager, plus a createWhisperPacket builder (service code 0009 / CMD_DIRECT_CHAT). The public method guards on blank targetId, authentication and connection state. Fix DirectChatDecoder: senderId/receiverId were swapped relative to the sender/receiver nicknames. Map senderId=parts[2], receiverId=parts[1] so the id and nickname pairs refer to the same user. Correct the type-field Javadoc (it is a sender flag, not a direction indicator). Add unit tests (whisper packet, DirectChat decoder, client guards) and a README example. Verified end-to-end against the live SOOP server: outgoing send echo and incoming receive both confirmed.
1 parent c1de10c commit d7ee8aa

9 files changed

Lines changed: 243 additions & 3 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
- **Virtual Threads**: JDK 21+ Virtual Thread 기반 비동기 메시지 처리
1515
- **통합 API 클라이언트**: `SOOPClient` 파사드로 인증, 방송 정보, 채널 정보, 채팅을 통합 관리
1616
- **다중 채팅 연결**: bid 기준 dedup된 `add`/`remove`/`get` API와 `(streamerId, event)`를 함께 받는 글로벌 이벤트 리스너 지원
17-
- **채팅 전송 지원**: `sendChat()` 메서드로 채팅 메시지 전송
17+
- **채팅 전송 지원**: `sendChat()` / `sendWhisper()` 메서드로 채팅·귓말 전송
1818
- **익명(읽기 전용) 연결**: 인증 없이 채팅 수신 가능
1919
- WebSocket 기반 자동 재연결 및 핑 메커니즘
2020
- 이벤트 리스너 에러 핸들링
@@ -216,6 +216,9 @@ chat.connectToChat();
216216

217217
// 4. 연결 완료 후 채팅 전송
218218
chat.sendChat("Hello!").join();
219+
220+
// 5. 특정 사용자에게 귓말 전송 ("targetUser" = 받는 사람 로그인 ID, 닉네임/(n) 형태 아님)
221+
chat.sendWhisper("targetUser", "안녕하세요").join();
219222
```
220223

221224
### 연결 상태 이벤트

lib/src/main/java/com/github/getcurrentthread/soopapi/client/SOOPChatClient.java

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
* SOOP 채팅 클라이언트.
2626
*
2727
* <p>{@link SOOPChatConfig}에 {@code authCookie}가 설정되지 않은 경우, 클라이언트는 익명(읽기 전용) 모드로 연결됩니다. 익명 모드에서는
28-
* 채팅 메시지를 수신할 수 있지만, {@link #sendChat(String)}을 호출하면 {@link AuthenticationException}이 발생합니다.
28+
* 채팅 메시지를 수신할 수 있지만, {@link #sendChat(String)}이나 {@link #sendWhisper(String, String)}을 호출하면 {@link
29+
* AuthenticationException}이 발생합니다.
2930
*/
3031
public class SOOPChatClient implements AutoCloseable {
3132
private static final Logger LOGGER = Logger.getLogger(SOOPChatClient.class.getName());
@@ -160,6 +161,33 @@ public CompletableFuture<Void> sendChat(String message) {
160161
return conn.sendChat(message);
161162
}
162163

164+
/**
165+
* 특정 사용자에게 귓말(다이렉트 채팅)을 전송합니다.
166+
*
167+
* @param targetId 받는 사람의 SOOP 로그인 ID (예: {@code "targetUser"}). 닉네임이나 런타임 {@code (n)} 접미사 형태가
168+
* 아닙니다.
169+
* @param message 전송할 메시지
170+
* @return 전송이 완료되면 완료되는 CompletableFuture. 인증되지 않았거나 연결되지 않은 경우, 또는 {@code targetId}가 비어 있는 경우
171+
* 예외로 완료됩니다.
172+
*/
173+
public CompletableFuture<Void> sendWhisper(String targetId, String message) {
174+
if (targetId == null || targetId.isBlank()) {
175+
return CompletableFuture.failedFuture(
176+
new IllegalArgumentException("targetId must not be null or blank"));
177+
}
178+
if (!config.isAuthenticated()) {
179+
return CompletableFuture.failedFuture(
180+
new AuthenticationException(
181+
"Authentication required. Set AuthCookie to send whisper messages."));
182+
}
183+
SOOPConnection conn = this.connection;
184+
if (conn == null || !isConnected) {
185+
return CompletableFuture.failedFuture(
186+
new IllegalStateException("Not connected. Call connectToChat() first."));
187+
}
188+
return conn.sendWhisper(targetId, message);
189+
}
190+
163191
/**
164192
* 현재 연결 위에서 가벼운 재연결을 수행합니다.
165193
*

lib/src/main/java/com/github/getcurrentthread/soopapi/connection/SOOPConnection.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ public CompletableFuture<Void> sendChat(String message) {
210210
return webSocketManager.sendChat(message);
211211
}
212212

213+
public CompletableFuture<Void> sendWhisper(String targetId, String message) {
214+
return webSocketManager.sendWhisper(targetId, message);
215+
}
216+
213217
public void disconnect() {
214218
connectionLock.lock();
215219
try {

lib/src/main/java/com/github/getcurrentthread/soopapi/decoder/message/DirectChatDecoder.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@
55
import com.github.getcurrentthread.soopapi.event.model.DirectChatEvent;
66
import com.github.getcurrentthread.soopapi.util.SOOPChatUtils;
77

8+
/**
9+
* 귓말(다이렉트 채팅) 메시지를 디코딩합니다.
10+
*
11+
* <p>프로토콜은 발신/수신 방향과 무관하게 동일한 필드 순서로 보냅니다: {@code message, receiverId, senderId, type, grade,
12+
* senderNickname, receiverNickname, flag, ...}. 즉 {@code parts[2]}(senderId)가 {@code
13+
* parts[5]}(senderNickname)와, {@code parts[1]}(receiverId)가 {@code parts[6]}(receiverNickname)와 짝을
14+
* 이룹니다.
15+
*
16+
* <p>{@code type}은 방향 지시자가 <b>아니다</b>. 실측상 발신자가 일반 사용자면 {@code 0}, 방송인(BJ)이면 {@code 1}로 관찰되었다(내가 보낸
17+
* echo·내가 받은 귓말 모두 포함). 방향은 {@code senderId}/{@code receiverId}를 내 로그인 ID와 비교해 판단하라. id 값에는 런타임
18+
* {@code (n)} 접미사(예: {@code "myUser(2)"})가 그대로 포함될 수 있다.
19+
*/
820
public class DirectChatDecoder implements IMessageDecoder {
921
private static final int MIN_PARTS = 8;
1022

@@ -15,8 +27,8 @@ public BaseEvent decode(String[] parts, String raw) {
1527
}
1628
return new DirectChatEvent(
1729
parts[0],
18-
parts[1],
1930
parts[2],
31+
parts[1],
2032
SOOPChatUtils.safeParseInt(parts[3], 0),
2133
parts[5],
2234
parts[6],

lib/src/main/java/com/github/getcurrentthread/soopapi/websocket/WebSocketManager.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,18 @@ public CompletableFuture<Void> sendChat(String message) {
417417
.thenRun(() -> {});
418418
}
419419

420+
public CompletableFuture<Void> sendWhisper(String targetId, String message) {
421+
String packet = WebSocketPacketBuilder.createWhisperPacket(targetId, message);
422+
return requireActiveSocket()
423+
.thenCompose(
424+
ws ->
425+
ws.sendText(packet, true)
426+
.orTimeout(
427+
config.getConnectionTimeout().toMillis(),
428+
TimeUnit.MILLISECONDS))
429+
.thenRun(() -> {});
430+
}
431+
420432
public CompletableFuture<Void> sendEnterInfo(String synAck) {
421433
LOGGER.fine("Sending ENTER_INFO packet...");
422434
String packet = WebSocketPacketBuilder.createEnterInfoPacket(synAck);

lib/src/main/java/com/github/getcurrentthread/soopapi/websocket/WebSocketPacketBuilder.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ public class WebSocketPacketBuilder {
1717
/** 채팅 메시지 전송의 명령 코드. */
1818
static final String CMD_CHAT = "0005";
1919

20+
/** 귓말(다이렉트 채팅) 전송의 명령 코드. */
21+
static final String CMD_DIRECT_CHAT = "0009";
22+
2023
/** 인증된 사용자 입장 정보의 명령 코드. */
2124
static final String CMD_ENTER_INFO = "0012";
2225

@@ -109,6 +112,22 @@ public static String createChatPacket(String message) {
109112
return buildPacket(CMD_CHAT, SOOPConstants.F + message + SOOPConstants.F.repeat(6));
110113
}
111114

115+
/**
116+
* 귓말(다이렉트 채팅) 전송 패킷을 생성합니다.
117+
*
118+
* <p>페이로드는 {@code F + message + F + targetId + F} 형태이며, {@code targetId}는 받는 사람의 SOOP 로그인 ID(예:
119+
* {@code "targetUser"})입니다. 닉네임이나 런타임 {@code (n)} 접미사 형태가 아닙니다.
120+
*
121+
* @param targetId 받는 사람의 로그인 ID
122+
* @param message 전송할 메시지
123+
* @return 송신 가능한 귓말 패킷 문자열
124+
*/
125+
public static String createWhisperPacket(String targetId, String message) {
126+
return buildPacket(
127+
CMD_DIRECT_CHAT,
128+
SOOPConstants.F + message + SOOPConstants.F + targetId + SOOPConstants.F);
129+
}
130+
112131
private static String buildPacket(String command, String data) {
113132
int byteLength = SOOPChatUtils.utf8ByteLength(data);
114133
String lengthStr = String.valueOf(byteLength);

lib/src/test/java/com/github/getcurrentthread/soopapi/client/SOOPChatClientTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,36 @@ void sendChat_withoutAuth_prioritizesAuthOverConnection() {
191191
"Authentication error should occur before connection error");
192192
}
193193

194+
@Test
195+
void sendWhisper_withoutAuth_throwsAuthenticationException() {
196+
SOOPChatConfig config =
197+
new SOOPChatConfig.Builder().bid("testStreamer").bno("12345").build();
198+
199+
SOOPChatClient client = new SOOPChatClient(config);
200+
201+
ExecutionException ex =
202+
assertThrows(
203+
ExecutionException.class,
204+
() -> client.sendWhisper("targetUser", "Hello!").get());
205+
206+
assertInstanceOf(AuthenticationException.class, ex.getCause());
207+
}
208+
209+
@Test
210+
void sendWhisper_withBlankTargetId_throwsIllegalArgument() {
211+
SOOPChatConfig config =
212+
new SOOPChatConfig.Builder().bid("testStreamer").bno("12345").build();
213+
214+
SOOPChatClient client = new SOOPChatClient(config);
215+
216+
// targetId 검증은 인증/연결 검사보다 먼저 수행되므로 미인증 상태에서도 IllegalArgumentException이 발생해야 함
217+
ExecutionException ex =
218+
assertThrows(
219+
ExecutionException.class, () -> client.sendWhisper(" ", "Hello!").get());
220+
221+
assertInstanceOf(IllegalArgumentException.class, ex.getCause());
222+
}
223+
194224
@Test
195225
void constructor_withoutBno_doesNotThrow() {
196226
SOOPChatConfig config = new SOOPChatConfig.Builder().bid("testStreamer").build();
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.github.getcurrentthread.soopapi.decoder.message;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import org.junit.jupiter.api.BeforeEach;
6+
import org.junit.jupiter.api.Test;
7+
8+
import com.github.getcurrentthread.soopapi.event.ChatEvent;
9+
import com.github.getcurrentthread.soopapi.event.model.BaseEvent;
10+
import com.github.getcurrentthread.soopapi.event.model.DirectChatEvent;
11+
12+
class DirectChatDecoderTest {
13+
14+
private DirectChatDecoder decoder;
15+
16+
@BeforeEach
17+
void setup() {
18+
decoder = new DirectChatDecoder();
19+
}
20+
21+
@Test
22+
void decode_incomingWhisper_mapsSenderAndReceiverConsistently() {
23+
// 방송인(BJ, peerUser)이 나(myUser)에게 보낸 귓말 — 발신자가 BJ라 type=1 (type은 방향이 아니라 발신자 구분 플래그)
24+
// 원본 필드 순서: message | receiverId | senderId | type | grade | senderNick | receiverNick |
25+
// flag | ...
26+
String[] parts = {
27+
"받은 귓말", "myUser(2)", "peerUser", "1", "3", "PeerNick", "MyNick", "100", "200", ""
28+
};
29+
30+
BaseEvent result = decoder.decode(parts, "raw");
31+
32+
assertNotNull(result);
33+
assertInstanceOf(DirectChatEvent.class, result);
34+
DirectChatEvent event = (DirectChatEvent) result;
35+
36+
assertEquals("받은 귓말", event.message());
37+
// 발신자 id와 닉네임이 같은 사람(상대)을 가리켜야 함
38+
assertEquals("peerUser", event.senderId());
39+
assertEquals("PeerNick", event.senderNickname());
40+
// 수신자 id와 닉네임이 같은 사람(나)을 가리켜야 함
41+
assertEquals("myUser(2)", event.receiverId());
42+
assertEquals("MyNick", event.receiverNickname());
43+
assertEquals(1, event.type());
44+
assertEquals("100", event.flag());
45+
assertEquals(ChatEvent.DIRECT_CHAT, event.eventType());
46+
}
47+
48+
@Test
49+
void decode_outgoingEcho_mapsSenderAndReceiverConsistently() {
50+
// 내가(myUser, 일반 사용자) peerUser에게 보낸 귓말의 echo — 발신자가 BJ가 아니라 type=0
51+
String[] parts = {
52+
"보낸 귓말", "peerUser", "myUser(2)", "0", "3", "MyNick", "PeerNick", "300", "400", ""
53+
};
54+
55+
BaseEvent result = decoder.decode(parts, "raw");
56+
57+
assertNotNull(result);
58+
DirectChatEvent event = (DirectChatEvent) result;
59+
60+
assertEquals("보낸 귓말", event.message());
61+
// echo에서 발신자는 나
62+
assertEquals("myUser(2)", event.senderId());
63+
assertEquals("MyNick", event.senderNickname());
64+
// 수신자는 상대
65+
assertEquals("peerUser", event.receiverId());
66+
assertEquals("PeerNick", event.receiverNickname());
67+
assertEquals(0, event.type());
68+
}
69+
70+
@Test
71+
void decode_tooFewParts_returnsNull() {
72+
String[] parts = {"msg", "a", "b", "1", "2", "nick", "0"}; // 7개 (< 8)
73+
74+
BaseEvent result = decoder.decode(parts, "raw");
75+
76+
assertNull(result, "Should return null for fewer than 8 parts");
77+
}
78+
79+
@Test
80+
void decode_emptyParts_returnsNull() {
81+
assertNull(decoder.decode(new String[0], "raw"));
82+
}
83+
}

lib/src/test/java/com/github/getcurrentthread/soopapi/websocket/WebSocketPacketBuilderTest.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,55 @@ void chatPacket_containsMessage() {
129129
assertTrue(packet.contains(message), "Chat packet should contain the message");
130130
}
131131

132+
@Test
133+
void whisperPacket_hasCorrectCommand() {
134+
String packet = WebSocketPacketBuilder.createWhisperPacket("targetUser", "hi");
135+
136+
assertTrue(packet.startsWith(SOOPConstants.ESC));
137+
String command =
138+
packet.substring(SOOPConstants.ESC.length(), SOOPConstants.ESC.length() + 4);
139+
assertEquals(WebSocketPacketBuilder.CMD_DIRECT_CHAT, command);
140+
}
141+
142+
@Test
143+
void whisperPacket_containsMessageAndTargetId() {
144+
String packet = WebSocketPacketBuilder.createWhisperPacket("targetUser", "Hello");
145+
146+
assertTrue(packet.contains("Hello"), "Whisper packet should contain the message");
147+
assertTrue(packet.contains("targetUser"), "Whisper packet should contain the targetId");
148+
}
149+
150+
@Test
151+
void whisperPacket_lengthField_matchesUtf8ByteLength() {
152+
// F + "안녕" + F + "targetUser" + F = 1 + 6 + 1 + 10 + 1 = 19 (한글은 UTF-8에서 글자당 3바이트)
153+
String packet = WebSocketPacketBuilder.createWhisperPacket("targetUser", "안녕");
154+
int lengthStart = SOOPConstants.ESC.length() + 4;
155+
String lengthField = packet.substring(lengthStart, lengthStart + 6);
156+
157+
assertEquals("000019", lengthField);
158+
}
159+
160+
@Test
161+
void whisperPacket_lengthField_isSixZeroPaddedDigits() {
162+
String packet = WebSocketPacketBuilder.createWhisperPacket("user", "hi");
163+
int lengthStart = SOOPConstants.ESC.length() + 4;
164+
String lengthField = packet.substring(lengthStart, lengthStart + 6);
165+
166+
assertTrue(lengthField.matches("\\d{6}"), "Length field should be 6 zero-padded digits");
167+
}
168+
169+
@Test
170+
void whisperPacket_fieldOrder_isMessageThenTarget() {
171+
// 페이로드 레이아웃 검증: F + message + F + targetId + F (메시지가 targetId보다 앞, 끝에 단일 F)
172+
String packet = WebSocketPacketBuilder.createWhisperPacket("targetUser", "msg");
173+
String expectedData =
174+
SOOPConstants.F + "msg" + SOOPConstants.F + "targetUser" + SOOPConstants.F;
175+
176+
assertTrue(
177+
packet.endsWith(expectedData),
178+
"message must precede targetId, with a single trailing F");
179+
}
180+
132181
@Test
133182
void enterInfoPacket_hasCorrectCommand() {
134183
String packet = WebSocketPacketBuilder.createEnterInfoPacket("syn_ack_value");

0 commit comments

Comments
 (0)