Skip to content

Commit 33ffbc2

Browse files
feat: add connection lifecycle events, anonymous mode, and comprehensive test suite
- Add DISCONNECTED, RECONNECTING, RECONNECTED connection state events - Support anonymous (read-only) connections without authentication - Add event listener error handling with EventEmitterException - Add bounds validation to all message decoders - Rename SoopClient to SOOPClient for consistent naming - Refactor WebSocket management and connection handling - Add comprehensive test coverage (13 new test files)
1 parent 6db0272 commit 33ffbc2

File tree

110 files changed

+3427
-1190
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

110 files changed

+3427
-1190
lines changed

README.md

Lines changed: 121 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
1010
- **이벤트 기반 아키텍처**: 타입 안전한 `on(event, handler)` 패턴으로 이벤트 구독
1111
- **Sealed 이벤트 계층**: `ChatBaseEvent`, `DonationBaseEvent`, `SystemBaseEvent` 등 6개 카테고리로 분류된 이벤트 타입
1212
- **93개 이벤트 타입 지원**: 채팅 메시지, 풍선, 이모티콘, 구독 등 모든 이벤트를 Java Record로 디코딩
13+
- **연결 상태 이벤트**: `DISCONNECTED`, `RECONNECTING`, `RECONNECTED` 이벤트로 연결 라이프사이클 추적
1314
- **Virtual Threads**: JDK 21+ Virtual Thread 기반 비동기 메시지 처리
14-
- **통합 API 클라이언트**: `SoopClient` 파사드로 인증, 방송 정보, 채널 정보, 채팅을 통합 관리
15+
- **통합 API 클라이언트**: `SOOPClient` 파사드로 인증, 방송 정보, 채널 정보, 채팅을 통합 관리
1516
- **채팅 전송 지원**: `sendChat()` 메서드로 채팅 메시지 전송
17+
- **익명(읽기 전용) 연결**: 인증 없이 채팅 수신 가능
1618
- WebSocket 기반 자동 재연결 및 핑 메커니즘
19+
- 이벤트 리스너 에러 핸들링
1720

1821
## 필요 조건
1922

@@ -65,25 +68,25 @@ dependencies {
6568

6669
## 사용 방법
6770

68-
### 통합 클라이언트 (SoopClient)
71+
### 통합 클라이언트 (SOOPClient)
6972

7073
```java
71-
import com.github.getcurrentthread.soopapi.SoopClient;
74+
import com.github.getcurrentthread.soopapi.SOOPClient;
7275
import com.github.getcurrentthread.soopapi.api.model.*;
7376
import com.github.getcurrentthread.soopapi.client.SOOPChatClient;
7477
import com.github.getcurrentthread.soopapi.event.ChatEvent;
7578
import com.github.getcurrentthread.soopapi.event.model.*;
7679

7780
public class Example {
7881
public static void main(String[] args) throws Exception {
79-
SoopClient client = new SoopClient();
82+
SOOPClient client = new SOOPClient();
8083

8184
// 방송 정보 조회
82-
LiveDetail detail = client.live.detail("streamerId").join();
85+
LiveDetail detail = client.live().detail("streamerId").join();
8386
System.out.println("방송 제목: " + detail.title());
8487

8588
// 채널 정보 조회
86-
StationInfo station = client.channel.station("streamerId").join();
89+
StationInfo station = client.channel().station("streamerId").join();
8790
System.out.println("스테이션: " + station.stationName());
8891

8992
// 채팅 연결 (이벤트 기반)
@@ -101,10 +104,8 @@ public class Example {
101104
System.out.println("구독 이벤트: " + e);
102105
});
103106

107+
// connectToChat().join()은 연결이 해제될 때까지 블로킹됩니다
104108
chat.connectToChat().join();
105-
106-
// 프로그램 실행 유지
107-
Thread.sleep(Long.MAX_VALUE);
108109
}
109110
}
110111
```
@@ -129,43 +130,130 @@ public class DirectExample {
129130
System.out.println(e.senderNickname() + ": " + e.message());
130131
});
131132

132-
client.connectToChat().join();
133-
134-
Thread.sleep(Long.MAX_VALUE);
133+
// connectAndAwait()는 연결이 해제될 때까지 블로킹됩니다
134+
client.connectAndAwait();
135135
}
136136
}
137137
```
138138

139+
> **참고**: `SOOPChatClient` 생성자는 네트워크 호출을 하지 않습니다. BNO가 설정되지 않은 경우 `connectToChat()` 호출 시 자동으로 해석됩니다.
140+
141+
### 익명(읽기 전용) 연결
142+
143+
`authCookie` 없이 연결하면 익명 모드로 동작합니다. 채팅 메시지와 이벤트를 수신할 수 있지만, `sendChat()`을 호출하면 `AuthenticationException`이 발생합니다.
144+
145+
```java
146+
// 인증 없이 읽기 전용으로 연결
147+
SOOPChatConfig config = new SOOPChatConfig.Builder()
148+
.bid("streamerId")
149+
.build();
150+
151+
SOOPChatClient client = new SOOPChatClient(config);
152+
153+
client.on(ChatEvent.CHAT_MESSAGE, (ChatMessageEvent e) -> {
154+
System.out.println(e.senderNickname() + ": " + e.message());
155+
});
156+
157+
client.connectAndAwait(); // 채팅 수신 가능, 전송 불가
158+
```
159+
139160
### 인증 (채팅 전송 시 필수)
140161

141162
읽기 전용(이벤트 수신)은 인증 없이 사용할 수 있지만, `sendChat()`으로 채팅을 전송하려면 반드시 인증이 필요합니다.
142163

143164
```java
144-
SoopClient client = new SoopClient();
165+
SOOPClient client = new SOOPClient();
145166

146-
// 1. 로그인
147-
AuthCookie cookie = client.auth.signIn("userId", "password").join();
148-
if (cookie.success()) {
149-
System.out.println("로그인 성공");
167+
// 1. 로그인 (실패 시 AuthenticationException 발생)
168+
AuthCookie cookie = client.auth().signIn("userId", "password").join();
169+
System.out.println("로그인 성공");
150170

151-
// 2. 인증된 설정으로 채팅 클라이언트 생성
152-
SOOPChatConfig config = new SOOPChatConfig.Builder()
153-
.bid("streamerId")
154-
.authCookie(cookie)
155-
.build();
171+
// 2. 인증된 설정으로 채팅 클라이언트 생성
172+
SOOPChatConfig config = new SOOPChatConfig.Builder()
173+
.bid("streamerId")
174+
.authCookie(cookie)
175+
.build();
156176

157-
SOOPChatClient chat = new SOOPChatClient(config);
177+
SOOPChatClient chat = new SOOPChatClient(config);
158178

159-
chat.on(ChatEvent.CHAT_MESSAGE, (ChatMessageEvent e) -> {
160-
System.out.println(e.senderNickname() + ": " + e.message());
161-
});
179+
chat.on(ChatEvent.CHAT_MESSAGE, (ChatMessageEvent e) -> {
180+
System.out.println(e.senderNickname() + ": " + e.message());
181+
});
162182

163-
// 3. 연결 후 채팅 전송
164-
chat.connectToChat().join();
165-
chat.sendChat("Hello!").join();
166-
}
183+
// 3. 연결 (비동기)
184+
chat.connectToChat();
185+
186+
// 4. 연결 완료 후 채팅 전송
187+
chat.sendChat("Hello!").join();
188+
```
189+
190+
### 연결 상태 이벤트
191+
192+
연결 라이프사이클을 추적할 수 있는 시스템 이벤트가 제공됩니다.
193+
194+
```java
195+
import com.github.getcurrentthread.soopapi.event.model.*;
196+
197+
// 연결 해제 감지
198+
client.on(ChatEvent.DISCONNECTED, (DisconnectedEvent e) -> {
199+
System.out.println("연결 해제: code=" + e.statusCode()
200+
+ ", reason=" + e.reason()
201+
+ ", error=" + e.causedByError());
202+
});
203+
204+
// 재연결 시도 감지
205+
client.on(ChatEvent.RECONNECTING, (ReconnectingEvent e) -> {
206+
System.out.println("재연결 시도 " + e.attemptNumber()
207+
+ "/" + e.maxAttempts()
208+
+ " (" + e.delayMs() + "ms 후)");
209+
});
210+
211+
// 재연결 완료 감지
212+
client.on(ChatEvent.RECONNECTED, (ReconnectedEvent e) -> {
213+
System.out.println("재연결 완료 (총 " + e.totalAttempts() + "회 시도)");
214+
});
215+
```
216+
217+
### 에러 핸들링
218+
219+
이벤트 리스너에서 발생하는 예외를 중앙에서 처리할 수 있습니다.
220+
221+
```java
222+
import com.github.getcurrentthread.soopapi.exception.EventEmitterException;
223+
224+
client.getEventEmitter().setErrorHandler((EventEmitterException ex) -> {
225+
System.err.println("이벤트 처리 중 오류: " + ex.getChatEvent());
226+
ex.printStackTrace();
227+
});
167228
```
168229

230+
### 고급 설정
231+
232+
`SOOPChatConfig.Builder`를 통해 연결 동작을 세밀하게 제어할 수 있습니다.
233+
234+
```java
235+
SOOPChatConfig config = new SOOPChatConfig.Builder()
236+
.bid("streamerId")
237+
.bno("12345") // 방송 번호 (생략 시 자동 해석)
238+
.connectionTimeout(Duration.ofSeconds(15)) // 연결 타임아웃 (기본: 30초)
239+
.maxRetryAttempts(3) // 최대 재시도 횟수 (기본: 5)
240+
.pingIntervalSeconds(30) // 핑 전송 간격 (기본: 60초)
241+
.initialPacketDelayMs(500) // 초기 패킷 대기 시간 (기본: 1000ms)
242+
.authCookie(cookie) // 인증 쿠키 (생략 시 읽기 전용)
243+
.build();
244+
```
245+
246+
## `connectToChat()` 동작 방식
247+
248+
v0.5.0부터 `connectToChat()`이 반환하는 `CompletableFuture`는 연결이 **해제**될 때 완료됩니다.
249+
250+
| 메서드 | 동작 |
251+
|--------|------|
252+
| `connectToChat()` | 연결 해제 시 완료되는 `CompletableFuture<Void>` 반환 |
253+
| `connectToChat().join()` | 연결이 해제될 때까지 현재 스레드를 블로킹 |
254+
| `connectAndAwait()` | `connectToChat().join()`의 편의 메서드 (블로킹) |
255+
| `connectToChattingBlocking()` | **Deprecated**`connectAndAwait()` 사용 권장 |
256+
169257
## 이벤트 타입
170258

171259
`ChatEvent` 열거형으로 모든 이벤트를 구독할 수 있습니다. 각 이벤트는 타입 안전한 Java Record로 디코딩됩니다.
@@ -184,6 +272,9 @@ if (cookie.success()) {
184272
| `VIDEO_BALLOON` | 105 | `VideoBalloonEvent` |
185273
| `LIVE_CAPTION` | 122 | `LiveCaptionEvent` |
186274
| `MISSION` | 121 | `MissionEvent` |
275+
| `DISCONNECTED` | -3 | `DisconnectedEvent` |
276+
| `RECONNECTING` | -4 | `ReconnectingEvent` |
277+
| `RECONNECTED` | -5 | `ReconnectedEvent` |
187278

188279
...그 외 83개 이벤트 타입 지원. 전체 목록은 `ChatEvent.java`를 참조하세요.
189280

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,71 @@
11
package com.github.getcurrentthread.soopapi;
22

3-
import com.github.getcurrentthread.soopapi.api.SoopAuth;
4-
import com.github.getcurrentthread.soopapi.api.SoopChannel;
5-
import com.github.getcurrentthread.soopapi.api.SoopHttpClient;
6-
import com.github.getcurrentthread.soopapi.api.SoopLive;
3+
import java.util.List;
4+
import java.util.concurrent.CopyOnWriteArrayList;
5+
import java.util.logging.Level;
6+
import java.util.logging.Logger;
7+
8+
import com.github.getcurrentthread.soopapi.api.SOOPAuth;
9+
import com.github.getcurrentthread.soopapi.api.SOOPChannel;
10+
import com.github.getcurrentthread.soopapi.api.SOOPHttpClient;
11+
import com.github.getcurrentthread.soopapi.api.SOOPLive;
712
import com.github.getcurrentthread.soopapi.client.SOOPChatClient;
813
import com.github.getcurrentthread.soopapi.config.SOOPChatConfig;
9-
import com.github.getcurrentthread.soopapi.config.SoopClientConfig;
14+
import com.github.getcurrentthread.soopapi.config.SOOPClientConfig;
15+
16+
public class SOOPClient implements AutoCloseable {
17+
private static final Logger LOGGER = Logger.getLogger(SOOPClient.class.getName());
1018

11-
public class SoopClient implements AutoCloseable {
12-
public final SoopAuth auth;
13-
public final SoopLive live;
14-
public final SoopChannel channel;
19+
private final SOOPAuth auth;
20+
private final SOOPLive live;
21+
private final SOOPChannel channel;
22+
private final SOOPHttpClient httpClient;
23+
private final List<SOOPChatClient> chatClients = new CopyOnWriteArrayList<>();
24+
25+
public SOOPClient() {
26+
this(new SOOPClientConfig.Builder().build());
27+
}
1528

16-
private final SoopHttpClient httpClient;
29+
public SOOPClient(SOOPClientConfig config) {
30+
this.httpClient = new SOOPHttpClient(config.getConnectionTimeout());
31+
this.auth = new SOOPAuth(httpClient);
32+
this.live = new SOOPLive(httpClient);
33+
this.channel = new SOOPChannel(httpClient);
34+
}
35+
36+
public SOOPAuth auth() {
37+
return auth;
38+
}
1739

18-
public SoopClient() {
19-
this(new SoopClientConfig.Builder().build());
40+
public SOOPLive live() {
41+
return live;
2042
}
2143

22-
public SoopClient(SoopClientConfig config) {
23-
this.httpClient = new SoopHttpClient();
24-
this.auth = new SoopAuth(httpClient);
25-
this.live = new SoopLive(httpClient);
26-
this.channel = new SoopChannel(httpClient);
44+
public SOOPChannel channel() {
45+
return channel;
2746
}
2847

2948
public SOOPChatClient chat(String streamerId) {
3049
SOOPChatConfig chatConfig = new SOOPChatConfig.Builder().bid(streamerId).build();
31-
return new SOOPChatClient(chatConfig);
50+
return chat(chatConfig);
3251
}
3352

3453
public SOOPChatClient chat(SOOPChatConfig config) {
35-
return new SOOPChatClient(config);
54+
SOOPChatClient client = new SOOPChatClient(config);
55+
chatClients.add(client);
56+
return client;
3657
}
3758

3859
@Override
3960
public void close() {
40-
// SoopHttpClient uses JDK HttpClient which is managed by the JVM
61+
for (SOOPChatClient client : chatClients) {
62+
try {
63+
client.close();
64+
} catch (Exception e) {
65+
LOGGER.log(Level.WARNING, "Error closing chat client", e);
66+
}
67+
}
68+
chatClients.clear();
69+
httpClient.close();
4170
}
4271
}

lib/src/main/java/com/github/getcurrentthread/soopapi/api/SoopAuth.java

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,18 @@
1111
import java.util.logging.Logger;
1212

1313
import com.github.getcurrentthread.soopapi.api.model.AuthCookie;
14+
import com.github.getcurrentthread.soopapi.exception.AuthenticationException;
1415
import com.github.getcurrentthread.soopapi.exception.SOOPChatException;
1516
import com.google.gson.JsonObject;
1617
import com.google.gson.JsonParser;
1718

18-
public class SoopAuth {
19-
private static final Logger LOGGER = Logger.getLogger(SoopAuth.class.getName());
19+
public class SOOPAuth {
20+
private static final Logger LOGGER = Logger.getLogger(SOOPAuth.class.getName());
2021
private static final String LOGIN_URL = "https://login.sooplive.co.kr/app/LoginAction.php";
2122

22-
private final SoopHttpClient httpClient;
23+
private final SOOPHttpClient httpClient;
2324

24-
public SoopAuth(SoopHttpClient httpClient) {
25+
public SOOPAuth(SOOPHttpClient httpClient) {
2526
this.httpClient = httpClient;
2627
}
2728

@@ -38,7 +39,8 @@ public CompletableFuture<AuthCookie> signIn(String userId, String password) {
3839
response -> {
3940
if (response.statusCode() != 200) {
4041
throw new SOOPChatException(
41-
"로그인 요청 실패. 상태 코드: " + response.statusCode());
42+
"Login request failed. Status code: "
43+
+ response.statusCode());
4244
}
4345

4446
try {
@@ -68,26 +70,14 @@ public CompletableFuture<AuthCookie> signIn(String userId, String password) {
6870
String reason =
6971
json.has("REASON")
7072
? json.get("REASON").getAsString()
71-
: "알 수 없는 오류";
72-
LOGGER.warning("로그인 실패: " + reason);
73-
return new AuthCookie(
74-
userId,
75-
false,
76-
response.body(),
77-
"",
78-
"",
79-
"",
80-
"",
81-
"",
82-
"",
83-
"",
84-
"",
85-
"",
86-
"");
73+
: "unknown error";
74+
throw new AuthenticationException("Login failed: " + reason);
8775
}
76+
} catch (AuthenticationException e) {
77+
throw e;
8878
} catch (Exception e) {
89-
LOGGER.log(Level.WARNING, "로그인 응답 파싱 오류", e);
90-
throw new SOOPChatException("로그인 응답 파싱 실패", e);
79+
LOGGER.log(Level.WARNING, "Error parsing login response", e);
80+
throw new SOOPChatException("Failed to parse login response", e);
9181
}
9282
});
9383
}
@@ -101,7 +91,7 @@ private Map<String, String> parseCookies(List<String> setCookieHeaders) {
10191
cookies.put(cookie.getName(), cookie.getValue());
10292
}
10393
} catch (Exception e) {
104-
LOGGER.log(Level.FINE, "쿠키 파싱 실패: " + header, e);
94+
LOGGER.log(Level.FINE, "Failed to parse cookie: " + header, e);
10595
}
10696
}
10797
return cookies;

0 commit comments

Comments
 (0)