chzzk-js
Version:
네이버 치지직(CHZZK) API를 위한 JavaScript SDK
1,636 lines (1,458 loc) • 50.4 kB
JavaScript
import axios from 'axios';
const BASE_OPEN_API_URL = 'https://openapi.chzzk.naver.com'; // Open API 도메인 ([1](https://chzzk.gitbook.io/chzzk/chzzk-api/tips))
const ACCOUNT_INTERLOCK_URL = 'https://chzzk.naver.com/account-interlock'; // 인증 코드 발급용 ([1](https://chzzk.gitbook.io/chzzk/chzzk-api/authorization))
const CHAT_WEBSOCKET_URL = 'wss://chat.chzzk.naver.com/chat'; // 채팅 웹소켓 URL
export default class Chzzk {
/**
* @param {Object} options - 인증 및 설정 정보를 담은 객체
* @param {string} options.clientId - 치지직 개발자센터에서 발급받은 Client ID
* @param {string} options.clientSecret - 치지직 개발자센터에서 발급받은 Client Secret
* @param {string} [options.accessToken] - 이미 발급받은 Access Token(있다면)
* @param {string} [options.refreshToken] - 이미 발급받은 Refresh Token(있다면)
* @param {boolean} [options.autoRefreshToken=true] - 토큰 만료 시 자동으로 갱신할지 여부
* @param {number} [options.tokenRefreshThresholdMs=300000] - 토큰 갱신 임계값(ms) - 기본 5분
*/
constructor(options) {
this.clientId = options.clientId;
this.clientSecret = options.clientSecret;
this.accessToken = options.accessToken || '';
this.refreshToken = options.refreshToken || '';
this.autoRefreshToken = options.autoRefreshToken !== false; // 기본값 true
this.tokenExpiry = null;
this.tokenRefreshThresholdMs = options.tokenRefreshThresholdMs || 5 * 60 * 1000; // 기본 5분
// 이벤트 핸들러 저장
this.eventHandlers = {
tokenRefresh: [],
tokenExpired: [],
chatMessage: [],
chatDonation: [],
chatSubscription: [],
chatNotice: [],
chatError: [],
chatConnected: [],
chatDisconnected: []
};
// 채팅 연결 관련
this.chatSocket = null;
this.chatChannelId = null;
this.chatHeartbeatInterval = null;
this.chatReconnectAttempts = 0;
this.chatMaxReconnectAttempts = 5;
this.httpClient = axios.create({
baseURL: BASE_OPEN_API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// 응답 인터셉터 추가 - API 응답 에러 처리 개선
this.httpClient.interceptors.response.use(
response => response,
async error => {
// 토큰 만료(401) 에러 자동 처리
if (this.autoRefreshToken &&
error.response &&
error.response.status === 401 &&
this.refreshToken) {
try {
// 토큰 갱신
await this.refreshAccessToken();
this._triggerEvent('tokenRefresh', { timestamp: Date.now() });
// 원래 요청 재시도
const originalRequest = error.config;
if (originalRequest.headers) {
originalRequest.headers['Authorization'] = `Bearer ${this.accessToken}`;
}
return this.httpClient(originalRequest);
} catch (refreshError) {
this._triggerEvent('tokenExpired', { timestamp: Date.now(), error: refreshError });
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
}
/**
* ----------------------------------------------------------------------------
* 1. 인증 관련 메서드
* ----------------------------------------------------------------------------
*/
/**
* 인증 코드(Authorization Code) 요청 URL 생성
* @param {string} redirectUri - 인증 완료 후 리다이렉트될 URL
* @param {string} state - CSRF 방지용 상태값
* @returns {string} 인증 코드 요청하기 위해 브라우저에서 이동할 URL
*
* 사용자는 이 URL로 리다이렉트되어 code/state를 받습니다.
* ([1](https://chzzk.gitbook.io/chzzk/chzzk-api/authorization))
*/
getAuthorizationCodeUrl(redirectUri, state) {
const params = new URLSearchParams({
clientId: this.clientId,
redirectUri,
state,
});
return `${ACCOUNT_INTERLOCK_URL}?${params.toString()}`;
}
/**
* 인증 코드로 Access Token 발급 받기
* @param {string} code - 인증 코드
* @param {string} state - state
* @returns {Promise<Object>} 토큰 정보 객체
* ([1](https://chzzk.gitbook.io/chzzk/chzzk-api/authorization))
*/
async issueAccessTokenByCode(code, state) {
const body = {
grantType: 'authorization_code',
clientId: this.clientId,
clientSecret: this.clientSecret,
code,
state,
};
try {
const res = await this.httpClient.post('/auth/v1/token', body);
this.accessToken = res.data.accessToken;
this.refreshToken = res.data.refreshToken;
this._setTokenExpiry(res.data.expiresIn);
return res.data;
} catch (error) {
this._handleApiError(error, '액세스 토큰 발급 실패');
}
}
/**
* Refresh Token으로 Access Token 갱신
* @returns {Promise<Object>} 갱신된 토큰 정보 객체
* ([1](https://chzzk.gitbook.io/chzzk/chzzk-api/authorization#치지직-access-token-갱신))
*/
async refreshAccessToken() {
if (!this.refreshToken) {
throw new Error('refreshToken이 설정되어 있지 않습니다.');
}
const body = {
grantType: 'refresh_token',
refreshToken: this.refreshToken,
clientId: this.clientId,
clientSecret: this.clientSecret,
};
try {
const res = await this.httpClient.post('/auth/v1/token', body);
this.accessToken = res.data.accessToken;
this.refreshToken = res.data.refreshToken;
this._setTokenExpiry(res.data.expiresIn);
return res.data;
} catch (error) {
this._handleApiError(error, '액세스 토큰 갱신 실패');
}
}
/**
* Access Token, Refresh Token 모두 폐기
* ([1](https://chzzk.gitbook.io/chzzk/chzzk-api/authorization#치지직-access-token-삭제))
* @param {string} token - 삭제할 토큰 (accessToken이나 refreshToken)
* @param {'access_token'|'refresh_token'} tokenTypeHint - 토큰 타입
* @returns {Promise<Object>} 토큰 폐기 결과
*/
async revokeToken(token, tokenTypeHint = 'access_token') {
const body = {
clientId: this.clientId,
clientSecret: this.clientSecret,
token,
tokenTypeHint,
};
try {
const res = await this.httpClient.post('/auth/v1/token/revoke', body);
// 토큰이 성공적으로 폐기되면 관련 값 초기화
if (tokenTypeHint === 'access_token' && token === this.accessToken) {
this.accessToken = '';
this.tokenExpiry = null;
} else if (tokenTypeHint === 'refresh_token' && token === this.refreshToken) {
this.refreshToken = '';
}
return res.data;
} catch (error) {
this._handleApiError(error, '토큰 폐기 실패');
}
}
/**
* ----------------------------------------------------------------------------
* 2. User (유저 정보) ([2](https://chzzk.gitbook.io/chzzk/chzzk-api/user))
* ----------------------------------------------------------------------------
*/
/**
* 현재 Access Token 소유 유저의 채널 정보를 조회
* @returns {Promise<Object>} 유저 정보 객체
* ([2](https://chzzk.gitbook.io/chzzk/chzzk-api/user#유저-정보-조회))
*/
async getMyUserInfo() {
await this._ensureValidToken();
try {
const res = await this.httpClient.get('/open/v1/users/me', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '유저 정보 조회 실패');
}
}
/**
* ----------------------------------------------------------------------------
* 3. Channel (채널 정보) ([3](https://chzzk.gitbook.io/chzzk/chzzk-api/channel))
* ----------------------------------------------------------------------------
*/
/**
* 여러 채널의 정보를 조회
* @param {string[]} channelIds - 조회할 채널 ID 배열
* @returns {Promise<Object>} 채널 정보 객체
* ([3](https://chzzk.gitbook.io/chzzk/chzzk-api/channel))
*/
async getChannels(channelIds = []) {
// Client 인증 API
// => Client-Id, Client-Secret 헤더 필요 ([1](https://chzzk.gitbook.io/chzzk/chzzk-api/tips#client-인증-api))
const headers = {
'Client-Id': this.clientId,
'Client-Secret': this.clientSecret,
};
const params = new URLSearchParams();
channelIds.forEach(id => params.append('channelIds', id));
try {
const res = await this.httpClient.get('/open/v1/channels', {
headers,
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '채널 정보 조회 실패');
}
}
/**
* 단일 채널 정보 조회 (편의 메서드)
* @param {string} channelId - 조회할 채널 ID
* @returns {Promise<Object>} 채널 정보 객체
*/
async getChannel(channelId) {
const result = await this.getChannels([channelId]);
if (result && result.code === 200 && result.content && result.content.channels && result.content.channels.length > 0) {
return {
...result,
content: result.content.channels[0]
};
}
return result;
}
/**
* ----------------------------------------------------------------------------
* 4. Category (카테고리 검색) ([4](https://chzzk.gitbook.io/chzzk/chzzk-api/category))
* ----------------------------------------------------------------------------
*/
/**
* 카테고리 검색
* @param {string} query - 검색어
* @param {number} [size=20] - 요청 사이즈
* @returns {Promise<Object>} 카테고리 검색 결과
*/
async searchCategory(query, size = 20) {
const headers = {
'Client-Id': this.clientId,
'Client-Secret': this.clientSecret,
};
const params = new URLSearchParams();
params.append('query', query);
params.append('size', size);
try {
const res = await this.httpClient.get('/open/v1/categories/search', {
headers,
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '카테고리 검색 실패');
}
}
/**
* ----------------------------------------------------------------------------
* 5. Live (방송) ([5](https://chzzk.gitbook.io/chzzk/chzzk-api/live))
* ----------------------------------------------------------------------------
*/
/**
* 라이브 목록 조회
* @param {number} [size=20] - 요청 사이즈
* @param {string} [nextCursor] - 다음 페이지 커서
* @returns {Promise<Object>} 라이브 목록 결과
* ([5](https://chzzk.gitbook.io/chzzk/chzzk-api/live#라이브-목록-조회))
*/
async getLiveList(size = 20, nextCursor = '') {
const headers = {
'Client-Id': this.clientId,
'Client-Secret': this.clientSecret,
};
const params = new URLSearchParams();
params.append('size', size);
if (nextCursor) {
params.append('next', nextCursor);
}
try {
const res = await this.httpClient.get('/open/v1/lives', {
headers,
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '라이브 목록 조회 실패');
}
}
/**
* 방송 스트림키 조회
* @returns {Promise<Object>} 스트림키 정보
* ([5](https://chzzk.gitbook.io/chzzk/chzzk-api/live#방송-스트림키-조회))
*/
async getStreamKey() {
await this._ensureValidToken();
try {
const res = await this.httpClient.get('/open/v1/streams/key', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '스트림키 조회 실패');
}
}
/**
* 방송 설정 조회
* @returns {Promise<Object>} 방송 설정 정보
* ([5](https://chzzk.gitbook.io/chzzk/chzzk-api/live#방송-설정-조회))
*/
async getLiveSetting() {
await this._ensureValidToken();
try {
const res = await this.httpClient.get('/open/v1/lives/setting', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '방송 설정 조회 실패');
}
}
/**
* 방송 설정 변경
* 참고: 필요한 값만 변경하면 됩니다 (PATCH)
* @param {Object} newSetting - 변경할 설정 값
* @returns {Promise<Object>} 변경 결과
* ([5](https://chzzk.gitbook.io/chzzk/chzzk-api/live#방송-설정-변경))
* 예시 -> { defaultLiveTitle: '새 제목', categoryId: '', tags: ['ABC'] }
*/
async updateLiveSetting(newSetting) {
await this._ensureValidToken();
try {
const res = await this.httpClient.patch('/open/v1/lives/setting', newSetting, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '방송 설정 변경 실패');
}
}
/**
* ----------------------------------------------------------------------------
* 6. Chat (채팅) ([6](https://chzzk.gitbook.io/chzzk/chzzk-api/chat))
* ----------------------------------------------------------------------------
*/
/**
* 채팅 메시지 전송
* @param {string} message - 전송할 메시지 (최대 100자)
* @returns {Promise<Object>} 전송 결과
* ([6](https://chzzk.gitbook.io/chzzk/chzzk-api/chat#채팅-메시지-전송))
*/
async sendChatMessage(message) {
await this._ensureValidToken();
try {
const body = { message };
const res = await this.httpClient.post('/open/v1/chats/send', body, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '채팅 메시지 전송 실패');
}
}
/**
* 채팅 공지 등록
* @param {Object} payload - 공지 내용
* @param {string} [payload.message] - 새 공지 메시지
* @param {string} [payload.messageId] - 기존 전송 메시지를 공지로 등록
* @returns {Promise<Object>} 등록 결과
* ([6](https://chzzk.gitbook.io/chzzk/chzzk-api/chat#채팅-공지-등록))
*/
async setChatNotice(payload) {
await this._ensureValidToken();
try {
const res = await this.httpClient.post('/open/v1/chats/notice', payload, {
headers: { Authorization: `Bearer ${this.accessToken}` },
});
return res.data;
} catch (error) {
this._handleApiError(error, '채팅 공지 등록 실패');
}
}
/**
* 채팅 설정 조회
* @returns {Promise<Object>} 채팅 설정 정보
* ([6](https://chzzk.gitbook.io/chzzk/chzzk-api/chat#채팅-설정-조회))
*/
async getChatSettings() {
await this._ensureValidToken();
try {
const res = await this.httpClient.get('/open/v1/chats/settings', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '채팅 설정 조회 실패');
}
}
/**
* 채팅 설정 변경
* @param {Object} newChatSettings - 변경할 설정 값
* @returns {Promise<Object>} 변경 결과
* ([6](https://chzzk.gitbook.io/chzzk/chzzk-api/chat#채팅-설정-변경))
* @param {string} [newChatSettings.chatAvailableCondition] - NONE 또는 REAL_NAME
* @param {string} [newChatSettings.chatAvailableGroup] - ALL, FOLLOWER, MANAGER, SUBSCRIBER
* @param {number} [newChatSettings.minFollowerMinute] - 팔로우 필요 시간(분)
* @param {boolean} [newChatSettings.allowSubscriberInFollowerMode] - 팔로워 모드에서 구독자 허용 여부
*/
async updateChatSettings(newChatSettings) {
await this._ensureValidToken();
try {
const res = await this.httpClient.put('/open/v1/chats/settings', newChatSettings, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '채팅 설정 변경 실패');
}
}
/**
* ----------------------------------------------------------------------------
* 7. Drops (드롭스) ([7](https://chzzk.gitbook.io/chzzk/chzzk-api/drops))
* ----------------------------------------------------------------------------
*/
/**
* 드롭스 리워드 지급 요청 조회
* @param {Object} options - 조회 옵션
* @param {number} [options.from] - 시작 인덱스
* @param {number} [options.size] - 조회 개수
* @param {string} [options.claimId] - 클레임 ID
* @param {string} [options.channelId] - 채널 ID
* @param {string} [options.campaignId] - 캠페인 ID
* @param {string} [options.categoryId] - 카테고리 ID
* @param {string} [options.fulfillmentState] - 지급 상태
* @returns {Promise<Object>} 조회 결과
* ([7](https://chzzk.gitbook.io/chzzk/chzzk-api/drops#1.2-드롭스-리워드-지급-요청-조회-api))
*/
async getDropsRewardClaims(options = {}) {
// Client 인증 필요
const headers = {
'Client-Id': this.clientId,
'Client-Secret': this.clientSecret,
};
const params = new URLSearchParams();
if (options.from) params.append('page.from', options.from);
if (options.size) params.append('page.size', options.size);
if (options.claimId) params.append('claimId', options.claimId);
if (options.channelId) params.append('channelId', options.channelId);
if (options.campaignId) params.append('campaignId', options.campaignId);
if (options.categoryId) params.append('categoryId', options.categoryId);
if (options.fulfillmentState) params.append('fulfillmentState', options.fulfillmentState);
try {
const res = await this.httpClient.get('/open/v1/drops/reward-claims', {
headers,
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '드롭스 리워드 지급 요청 조회 실패');
}
}
/**
* 드롭스 리워드 지급 상태 변경
* @param {string[]} claimIds - 클레임 ID 배열
* @param {'CLAIMED'|'FULFILLED'} fulfillmentState - 변경할 상태
* @returns {Promise<Object>} 변경 결과
* ([7](https://chzzk.gitbook.io/chzzk/chzzk-api/drops#1.3-드롭스-리워드-지급-api))
*/
async updateDropsRewardClaims(claimIds, fulfillmentState) {
const headers = {
'Client-Id': this.clientId,
'Client-Secret': this.clientSecret,
};
const body = {
claimIds,
fulfillmentState,
};
try {
const res = await this.httpClient.put('/open/v1/drops/reward-claims', body, {
headers,
});
return res.data;
} catch (error) {
this._handleApiError(error, '드롭스 리워드 지급 상태 변경 실패');
}
}
/**
* ----------------------------------------------------------------------------
* 8. 이벤트 핸들링
* ----------------------------------------------------------------------------
*/
/**
* 이벤트 리스너 등록
* @param {string} eventName - 이벤트 이름
* @param {Function} handler - 이벤트 발생 시 호출할 함수
* @returns {Function} 리스너 제거를 위한 함수
*/
on(eventName, handler) {
if (!this.eventHandlers[eventName]) {
throw new Error(`지원하지 않는 이벤트: ${eventName}`);
}
this.eventHandlers[eventName].push(handler);
// 리스너 제거 함수 반환
return () => {
this.off(eventName, handler);
};
}
/**
* 이벤트 리스너 제거
* @param {string} eventName - 이벤트 이름
* @param {Function} handler - 제거할 핸들러 함수
*/
off(eventName, handler) {
if (!this.eventHandlers[eventName]) {
return;
}
const index = this.eventHandlers[eventName].indexOf(handler);
if (index !== -1) {
this.eventHandlers[eventName].splice(index, 1);
}
}
/**
* 이벤트 한 번만 수신
* @param {string} eventName - 이벤트 이름
* @param {Function} handler - 이벤트 핸들러
* @returns {Function} 리스너 제거를 위한 함수
*/
once(eventName, handler) {
const onceHandler = (data) => {
this.off(eventName, onceHandler);
handler(data);
};
return this.on(eventName, onceHandler);
}
/**
* ----------------------------------------------------------------------------
* 9. 실시간 채팅 (웹소켓)
* ----------------------------------------------------------------------------
*/
/**
* 채팅 웹소켓 연결
* @param {string} channelId - 연결할 채널 ID
* @returns {Promise<void>}
*/
async connectChat(channelId) {
if (!channelId) {
throw new Error('채널 ID가 필요합니다.');
}
if (this.chatSocket) {
await this.disconnectChat();
}
this.chatChannelId = channelId;
try {
// 채팅 접속 토큰 가져오기
const chatAccessToken = await this._getChatAccessToken(channelId);
return new Promise((resolve, reject) => {
try {
// 웹소켓 연결
this.chatSocket = new WebSocket(CHAT_WEBSOCKET_URL);
this.chatSocket.onopen = () => {
// 웹소켓 연결 성공 후 인증 메시지 전송
this._sendChatAuthMessage(chatAccessToken);
// 하트비트 시작
this._startChatHeartbeat();
this._triggerEvent('chatConnected', { channelId });
resolve();
};
this.chatSocket.onclose = (event) => {
this._cleanupChatConnection();
this._triggerEvent('chatDisconnected', { channelId, code: event.code, reason: event.reason });
// 자동 재연결 시도
if (this.chatReconnectAttempts < this.chatMaxReconnectAttempts) {
this.chatReconnectAttempts++;
setTimeout(() => {
this.connectChat(channelId).catch(() => {});
}, 1000 * Math.pow(2, this.chatReconnectAttempts));
}
};
this.chatSocket.onerror = (error) => {
this._triggerEvent('chatError', { error });
reject(error);
};
this.chatSocket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this._handleChatMessage(message);
} catch (error) {
this._triggerEvent('chatError', { error, rawMessage: event.data });
}
};
} catch (error) {
reject(error);
}
});
} catch (error) {
this._handleApiError(error, '채팅 연결 실패');
}
}
/**
* 채팅 연결 종료
* @returns {Promise<void>}
*/
async disconnectChat() {
return new Promise((resolve) => {
if (!this.chatSocket) {
resolve();
return;
}
this._cleanupChatConnection();
if (this.chatSocket.readyState === WebSocket.OPEN) {
this.chatSocket.onclose = () => {
this.chatSocket = null;
resolve();
};
this.chatSocket.close();
} else {
this.chatSocket = null;
resolve();
}
});
}
/**
* 채팅 하트비트 시작
* @private
*/
_startChatHeartbeat() {
this._stopChatHeartbeat();
this.chatHeartbeatInterval = setInterval(() => {
if (this.chatSocket && this.chatSocket.readyState === WebSocket.OPEN) {
this.chatSocket.send(JSON.stringify({ type: 'PING' }));
}
}, 30000); // 30초마다 핑
}
/**
* 채팅 하트비트 중지
* @private
*/
_stopChatHeartbeat() {
if (this.chatHeartbeatInterval) {
clearInterval(this.chatHeartbeatInterval);
this.chatHeartbeatInterval = null;
}
}
/**
* 채팅 연결 정리
* @private
*/
_cleanupChatConnection() {
this._stopChatHeartbeat();
this.chatReconnectAttempts = 0;
}
/**
* 채팅 인증 메시지 전송
* @param {string} chatAccessToken - 채팅 접속 토큰
* @private
*/
_sendChatAuthMessage(chatAccessToken) {
if (!this.chatSocket || this.chatSocket.readyState !== WebSocket.OPEN) {
return;
}
const authMessage = {
type: 'AUTH',
token: chatAccessToken,
channelId: this.chatChannelId,
};
this.chatSocket.send(JSON.stringify(authMessage));
}
/**
* 채팅 메시지 처리
* @param {Object} message - 받은 메시지 객체
* @private
*/
_handleChatMessage(message) {
switch (message.type) {
case 'CHAT':
this._triggerEvent('chatMessage', {
type: 'message',
channelId: this.chatChannelId,
userId: message.userId,
nickname: message.nickname,
message: message.content,
badges: message.badges || [],
timestamp: message.timestamp || Date.now()
});
break;
case 'DONATION':
this._triggerEvent('chatDonation', {
type: 'donation',
channelId: this.chatChannelId,
userId: message.userId,
nickname: message.nickname,
message: message.content,
amount: message.amount,
currency: message.currency || 'KRW',
isAnonymous: !!message.isAnonymous,
badges: message.badges || [],
timestamp: message.timestamp || Date.now()
});
break;
case 'SUBSCRIPTION':
this._triggerEvent('chatSubscription', {
type: 'subscription',
channelId: this.chatChannelId,
userId: message.userId,
nickname: message.nickname,
message: message.content,
months: message.months || 1,
tier: message.tier || 1,
tierName: message.tierName || '기본 구독',
badges: message.badges || [],
timestamp: message.timestamp || Date.now()
});
break;
case 'NOTICE':
this._triggerEvent('chatNotice', {
type: 'notice',
channelId: this.chatChannelId,
message: message.content,
noticeType: message.noticeType || 'NORMAL',
timestamp: message.timestamp || Date.now()
});
break;
case 'PONG':
// 핑에 대한 응답, 무시
break;
default:
// 알 수 없는 메시지 타입
break;
}
}
/**
* 채팅 접속 토큰 가져오기
* @param {string} channelId - 채널 ID
* @returns {Promise<string>} 채팅 접속 토큰
* @private
*/
async _getChatAccessToken(channelId) {
await this._ensureValidToken();
try {
const res = await this.httpClient.get(`/open/v1/chats/access-token?channelId=${channelId}`, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
if (res.data && res.data.content && res.data.content.accessToken) {
return res.data.content.accessToken;
}
throw new Error('채팅 접속 토큰을 찾을 수 없습니다.');
} catch (error) {
this._handleApiError(error, '채팅 접속 토큰 가져오기 실패');
}
}
/**
* ----------------------------------------------------------------------------
* 내부 유틸리티 메서드
* ----------------------------------------------------------------------------
*/
/**
* 토큰 만료 시간 설정
* @param {number} expiresIn - 토큰 만료 시간(초)
* @private
*/
_setTokenExpiry(expiresIn) {
if (expiresIn) {
// 만료 시간을 설정 (약간의 버퍼 적용)
const bufferMs = 10 * 1000; // 10초 버퍼
this.tokenExpiry = Date.now() + (expiresIn * 1000) - bufferMs;
}
}
/**
* 유효한 토큰 확보
* @private
*/
async _ensureValidToken() {
if (!this.accessToken) {
throw new Error('Access Token이 설정되어 있지 않습니다. 먼저 발급받으세요.');
}
// 자동 갱신이 활성화되어 있고, 토큰 만료 시간이 설정되어 있으며, 임계값에 도달한 경우
if (this.autoRefreshToken &&
this.tokenExpiry &&
this.refreshToken &&
Date.now() + this.tokenRefreshThresholdMs > this.tokenExpiry) {
await this.refreshAccessToken();
}
}
/**
* 이벤트 트리거
* @param {string} eventName - 이벤트 이름
* @param {Object} data - 이벤트 데이터
* @private
*/
_triggerEvent(eventName, data) {
if (!this.eventHandlers[eventName]) {
return;
}
for (const handler of this.eventHandlers[eventName]) {
try {
handler(data);
} catch (error) {
console.error(`이벤트 핸들러 에러 (${eventName}):`, error);
}
}
}
/**
* API 에러 처리
* @param {Error} error - 발생한 에러
* @param {string} message - 사용자 친화적 메시지
* @private
*/
_handleApiError(error, message) {
if (error.response && error.response.data) {
// API 응답에 에러 정보가 포함된 경우
const errorData = error.response.data;
const errorMsg = `${message}: ${errorData.message || JSON.stringify(errorData)}`;
throw new Error(errorMsg);
} else if (error.request) {
// 요청은 보냈으나 응답을 받지 못한 경우
throw new Error(`${message}: 서버로부터 응답이 없습니다. 네트워크 연결을 확인하세요.`);
} else {
// 요청 설정 중 에러가 발생한 경우
throw new Error(`${message}: ${error.message}`);
}
}
/**
* ----------------------------------------------------------------------------
* 10. 검색 API
* ----------------------------------------------------------------------------
*/
/**
* 라이브 방송 검색
* @param {Object} options - 검색 옵션
* @param {string} options.query - 검색어
* @param {number} [options.size=20] - 요청 사이즈
* @param {string} [options.next] - 다음 페이지 커서
* @param {string} [options.categoryId] - 카테고리 ID 필터링
* @returns {Promise<Object>} 검색 결과
*/
async searchLives(options) {
if (!options.query) {
throw new Error('검색어가 필요합니다.');
}
const headers = {
'Client-Id': this.clientId,
'Client-Secret': this.clientSecret,
};
const params = new URLSearchParams();
params.append('query', options.query);
if (options.size) params.append('size', options.size);
if (options.next) params.append('next', options.next);
if (options.categoryId) params.append('categoryId', options.categoryId);
try {
const res = await this.httpClient.get('/open/v1/search/lives', {
headers,
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '라이브 방송 검색 실패');
}
}
/**
* 채널 검색
* @param {Object} options - 검색 옵션
* @param {string} options.query - 검색어
* @param {number} [options.size=20] - 요청 사이즈
* @param {string} [options.next] - 다음 페이지 커서
* @returns {Promise<Object>} 검색 결과
*/
async searchChannels(options) {
if (!options.query) {
throw new Error('검색어가 필요합니다.');
}
const headers = {
'Client-Id': this.clientId,
'Client-Secret': this.clientSecret,
};
const params = new URLSearchParams();
params.append('query', options.query);
if (options.size) params.append('size', options.size);
if (options.next) params.append('next', options.next);
try {
const res = await this.httpClient.get('/open/v1/search/channels', {
headers,
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '채널 검색 실패');
}
}
/**
* 통합 검색
* @param {Object} options - 검색 옵션
* @param {string} options.query - 검색어
* @param {number} [options.size=20] - 요청 사이즈
* @param {string} [options.next] - 다음 페이지 커서
* @returns {Promise<Object>} 검색 결과 (채널, 라이브, VOD 통합)
*/
async search(options) {
if (!options.query) {
throw new Error('검색어가 필요합니다.');
}
const headers = {
'Client-Id': this.clientId,
'Client-Secret': this.clientSecret,
};
const params = new URLSearchParams();
params.append('query', options.query);
if (options.size) params.append('size', options.size);
if (options.next) params.append('next', options.next);
try {
const res = await this.httpClient.get('/open/v1/search', {
headers,
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '통합 검색 실패');
}
}
/**
* ----------------------------------------------------------------------------
* 11. VOD (비디오 온 디맨드)
* ----------------------------------------------------------------------------
*/
/**
* VOD 목록 조회
* @param {Object} options - 조회 옵션
* @param {string} [options.channelId] - 특정 채널의 VOD만 조회할 경우 채널 ID
* @param {number} [options.size=20] - 요청 사이즈
* @param {string} [options.next] - 다음 페이지 커서
* @returns {Promise<Object>} VOD 목록
*/
async getVods(options = {}) {
const headers = {
'Client-Id': this.clientId,
'Client-Secret': this.clientSecret,
};
const params = new URLSearchParams();
if (options.channelId) params.append('channelId', options.channelId);
if (options.size) params.append('size', options.size);
if (options.next) params.append('next', options.next);
try {
const res = await this.httpClient.get('/open/v1/vods', {
headers,
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, 'VOD 목록 조회 실패');
}
}
/**
* VOD 상세 정보 조회
* @param {string} vodId - VOD ID
* @returns {Promise<Object>} VOD 상세 정보
*/
async getVod(vodId) {
if (!vodId) {
throw new Error('VOD ID가 필요합니다.');
}
const headers = {
'Client-Id': this.clientId,
'Client-Secret': this.clientSecret,
};
try {
const res = await this.httpClient.get(`/open/v1/vods/${vodId}`, {
headers,
});
return res.data;
} catch (error) {
this._handleApiError(error, 'VOD 상세 정보 조회 실패');
}
}
/**
* 내 채널의 VOD 목록 조회
* @param {Object} options - 조회 옵션
* @param {number} [options.size=20] - 요청 사이즈
* @param {string} [options.next] - 다음 페이지 커서
* @returns {Promise<Object>} VOD 목록
*/
async getMyVods(options = {}) {
await this._ensureValidToken();
const params = new URLSearchParams();
if (options.size) params.append('size', options.size);
if (options.next) params.append('next', options.next);
try {
const res = await this.httpClient.get('/open/v1/vods/my', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '내 VOD 목록 조회 실패');
}
}
/**
* VOD 업로드 URL 생성
* @param {Object} metadata - VOD 메타데이터
* @param {string} metadata.title - VOD 제목
* @param {string} [metadata.description] - VOD 설명
* @param {string} [metadata.categoryId] - 카테고리 ID
* @param {string[]} [metadata.tags] - 태그 배열
* @param {string} [metadata.visibility="PUBLIC"] - 공개 여부 (PUBLIC, PRIVATE, UNLISTED)
* @returns {Promise<Object>} 업로드 URL 정보
*/
async createVodUploadUrl(metadata) {
await this._ensureValidToken();
if (!metadata.title) {
throw new Error('VOD 제목이 필요합니다.');
}
try {
const res = await this.httpClient.post('/open/v1/vods/upload-urls', metadata, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, 'VOD 업로드 URL 생성 실패');
}
}
/**
* VOD 메타데이터 수정
* @param {string} vodId - VOD ID
* @param {Object} metadata - 수정할 메타데이터
* @param {string} [metadata.title] - VOD 제목
* @param {string} [metadata.description] - VOD 설명
* @param {string} [metadata.categoryId] - 카테고리 ID
* @param {string[]} [metadata.tags] - 태그 배열
* @param {string} [metadata.visibility] - 공개 여부 (PUBLIC, PRIVATE, UNLISTED)
* @returns {Promise<Object>} 수정 결과
*/
async updateVodMetadata(vodId, metadata) {
await this._ensureValidToken();
if (!vodId) {
throw new Error('VOD ID가 필요합니다.');
}
try {
const res = await this.httpClient.patch(`/open/v1/vods/${vodId}`, metadata, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, 'VOD 메타데이터 수정 실패');
}
}
/**
* VOD 삭제
* @param {string} vodId - 삭제할 VOD ID
* @returns {Promise<Object>} 삭제 결과
*/
async deleteVod(vodId) {
await this._ensureValidToken();
if (!vodId) {
throw new Error('VOD ID가 필요합니다.');
}
try {
const res = await this.httpClient.delete(`/open/v1/vods/${vodId}`, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, 'VOD 삭제 실패');
}
}
/**
* ----------------------------------------------------------------------------
* 12. 팔로워/구독자 관리
* ----------------------------------------------------------------------------
*/
/**
* 내 채널의 팔로워 목록 조회
* @param {Object} options - 조회 옵션
* @param {number} [options.size=20] - 요청 사이즈
* @param {string} [options.next] - 다음 페이지 커서
* @returns {Promise<Object>} 팔로워 목록
*/
async getMyFollowers(options = {}) {
await this._ensureValidToken();
const params = new URLSearchParams();
if (options.size) params.append('size', options.size);
if (options.next) params.append('next', options.next);
try {
const res = await this.httpClient.get('/open/v1/users/me/followers', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '팔로워 목록 조회 실패');
}
}
/**
* 내가 팔로우한 채널 목록 조회
* @param {Object} options - 조회 옵션
* @param {number} [options.size=20] - 요청 사이즈
* @param {string} [options.next] - 다음 페이지 커서
* @returns {Promise<Object>} 팔로우 목록
*/
async getMyFollowings(options = {}) {
await this._ensureValidToken();
const params = new URLSearchParams();
if (options.size) params.append('size', options.size);
if (options.next) params.append('next', options.next);
try {
const res = await this.httpClient.get('/open/v1/users/me/followings', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '팔로우 목록 조회 실패');
}
}
/**
* 채널 팔로우
* @param {string} channelId - 팔로우할 채널 ID
* @returns {Promise<Object>} 팔로우 결과
*/
async followChannel(channelId) {
await this._ensureValidToken();
if (!channelId) {
throw new Error('채널 ID가 필요합니다.');
}
try {
const res = await this.httpClient.post(`/open/v1/channels/${channelId}/follow`, null, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '채널 팔로우 실패');
}
}
/**
* 채널 팔로우 취소
* @param {string} channelId - 팔로우 취소할 채널 ID
* @returns {Promise<Object>} 팔로우 취소 결과
*/
async unfollowChannel(channelId) {
await this._ensureValidToken();
if (!channelId) {
throw new Error('채널 ID가 필요합니다.');
}
try {
const res = await this.httpClient.delete(`/open/v1/channels/${channelId}/follow`, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '채널 팔로우 취소 실패');
}
}
/**
* 구독자 목록 조회
* @param {Object} options - 조회 옵션
* @param {number} [options.size=20] - 요청 사이즈
* @param {string} [options.next] - 다음 페이지 커서
* @returns {Promise<Object>} 구독자 목록
*/
async getMySubscribers(options = {}) {
await this._ensureValidToken();
const params = new URLSearchParams();
if (options.size) params.append('size', options.size);
if (options.next) params.append('next', options.next);
try {
const res = await this.httpClient.get('/open/v1/users/me/subscribers', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '구독자 목록 조회 실패');
}
}
/**
* 구독자 통계 조회
* @returns {Promise<Object>} 구독자 통계 정보
*/
async getMySubscriberStats() {
await this._ensureValidToken();
try {
const res = await this.httpClient.get('/open/v1/users/me/subscriber-stats', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '구독자 통계 조회 실패');
}
}
/**
* 내 구독 정보 조회
* @returns {Promise<Object>} 구독 정보
*/
async getMySubscriptions() {
await this._ensureValidToken();
try {
const res = await this.httpClient.get('/open/v1/users/me/subscriptions', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '구독 정보 조회 실패');
}
}
/**
* ----------------------------------------------------------------------------
* 13. 채팅 모더레이션
* ----------------------------------------------------------------------------
*/
/**
* 채팅 금지 사용자 목록 조회
* @param {Object} options - 조회 옵션
* @param {number} [options.size=20] - 요청 사이즈
* @param {string} [options.next] - 다음 페이지 커서
* @returns {Promise<Object>} 채팅 금지 사용자 목록
*/
async getChatBannedUsers(options = {}) {
await this._ensureValidToken();
const params = new URLSearchParams();
if (options.size) params.append('size', options.size);
if (options.next) params.append('next', options.next);
try {
const res = await this.httpClient.get('/open/v1/chats/banned-users', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '채팅 금지 사용자 목록 조회 실패');
}
}
/**
* 채팅 금지 사용자 추가
* @param {Object} banInfo - 금지 정보
* @param {string} banInfo.userId - 금지할 사용자 ID
* @param {number} [banInfo.durationSeconds] - 금지 기간(초), 없으면 영구 금지
* @param {string} [banInfo.reason] - 금지 사유
* @returns {Promise<Object>} 채팅 금지 결과
*/
async banChatUser(banInfo) {
await this._ensureValidToken();
if (!banInfo.userId) {
throw new Error('사용자 ID가 필요합니다.');
}
try {
const res = await this.httpClient.post('/open/v1/chats/banned-users', banInfo, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '채팅 금지 사용자 추가 실패');
}
}
/**
* 채팅 금지 사용자 해제
* @param {string} userId - 금지 해제할 사용자 ID
* @returns {Promise<Object>} 채팅 금지 해제 결과
*/
async unbanChatUser(userId) {
await this._ensureValidToken();
if (!userId) {
throw new Error('사용자 ID가 필요합니다.');
}
try {
const res = await this.httpClient.delete(`/open/v1/chats/banned-users/${userId}`, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '채팅 금지 사용자 해제 실패');
}
}
/**
* 채팅 모더레이터 목록 조회
* @param {Object} options - 조회 옵션
* @param {number} [options.size=20] - 요청 사이즈
* @param {string} [options.next] - 다음 페이지 커서
* @returns {Promise<Object>} 채팅 모더레이터 목록
*/
async getChatModerators(options = {}) {
await this._ensureValidToken();
const params = new URLSearchParams();
if (options.size) params.append('size', options.size);
if (options.next) params.append('next', options.next);
try {
const res = await this.httpClient.get('/open/v1/chats/moderators', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '채팅 모더레이터 목록 조회 실패');
}
}
/**
* 채팅 모더레이터 추가
* @param {string} userId - 모더레이터로 추가할 사용자 ID
* @returns {Promise<Object>} 모더레이터 추가 결과
*/
async addChatModerator(userId) {
await this._ensureValidToken();
if (!userId) {
throw new Error('사용자 ID가 필요합니다.');
}
try {
const res = await this.httpClient.post('/open/v1/chats/moderators', { userId }, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '채팅 모더레이터 추가 실패');
}
}
/**
* 채팅 모더레이터 제거
* @param {string} userId - 모더레이터에서 제거할 사용자 ID
* @returns {Promise<Object>} 모더레이터 제거 결과
*/
async removeChatModerator(userId) {
await this._ensureValidToken();
if (!userId) {
throw new Error('사용자 ID가 필요합니다.');
}
try {
const res = await this.httpClient.delete(`/open/v1/chats/moderators/${userId}`, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '채팅 모더레이터 제거 실패');
}
}
/**
* 채팅 필터링 단어 목록 조회
* @param {Object} options - 조회 옵션
* @param {number} [options.size=20] - 요청 사이즈
* @param {string} [options.next] - 다음 페이지 커서
* @returns {Promise<Object>} 채팅 필터링 단어 목록
*/
async getChatFilterWords(options = {}) {
await this._ensureValidToken();
const params = new URLSearchParams();
if (options.size) params.append('size', options.size);
if (options.next) params.append('next', options.next);
try {
const res = await this.httpClient.get('/open/v1/chats/filter-words', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
params,
});
return res.data;
} catch (error) {
this._handleApiError(error, '채팅 필터링 단어 목록 조회 실패');
}
}
/**
* 채팅 필터링 단어 추가
* @param {string} word - 필터링할 단어
* @returns {Promise<Object>} 필터링 단어 추가 결과
*/
async addChatFilterWord(word) {
await this._ensureValidToken();
if (!word) {
throw new Error('필터링할 단어가 필요합니다.');
}
try {
const res = await this.httpClient.post('/open/v1/chats/filter-words', { word }, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '채팅 필터링 단어 추가 실패');
}
}
/**
* 채팅 필터링 단어 삭제
* @param {string} wordId - 삭제할 필터링 단어 ID
* @returns {Promise<Object>} 필터링 단어 삭제 결과
*/
async removeChatFilterWord(wordId) {
await this._ensureValidToken();
if (!wordId) {
throw new Error('필터링 단어 ID가 필요합니다.');
}
try {
const res = await this.httpClient.delete(`/open/v1/chats/filter-words/${wordId}`, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
return res.data;
} catch (error) {
this._handleApiError(error, '채팅 필터링 단어 삭제 실패');
}
}
}