치지직 채팅창 인식 추가 및 주사위 횟수 증가 추가.

This commit is contained in:
kimyu
2026-09-16 02:21:39 +09:00
parent 171a4e19f6
commit 7c402dfd8f
22 changed files with 2133 additions and 90 deletions

135
lib/chzzk/channel.ts Normal file
View File

@@ -0,0 +1,135 @@
/** 치지직 라이브/채널 URL에서 channelId 추출 */
export function parseChzzkChannelId(input: string): string | null {
const raw = input.trim();
if (!raw) return null;
// 해시만 붙여 넣은 경우
if (/^[a-f0-9]{32}$/i.test(raw)) return raw.toLowerCase();
try {
const url = new URL(raw.startsWith('http') ? raw : `https://${raw}`);
if (!url.hostname.includes('chzzk.naver.com')) return null;
const parts = url.pathname.split('/').filter(Boolean);
// /live/{id} | /{id} | /channel/{id}
const liveIdx = parts.indexOf('live');
if (liveIdx >= 0 && parts[liveIdx + 1]) {
const id = parts[liveIdx + 1];
return /^[a-f0-9]{32}$/i.test(id) ? id.toLowerCase() : null;
}
const channelIdx = parts.indexOf('channel');
if (channelIdx >= 0 && parts[channelIdx + 1]) {
const id = parts[channelIdx + 1];
return /^[a-f0-9]{32}$/i.test(id) ? id.toLowerCase() : null;
}
if (parts[0] && /^[a-f0-9]{32}$/i.test(parts[0])) {
return parts[0].toLowerCase();
}
} catch {
return null;
}
return null;
}
const CHZZK_API = 'https://api.chzzk.naver.com';
const GAME_API = 'https://comm-api.game.naver.com/nng_main';
const DEFAULT_HEADERS = {
Accept: 'application/json',
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
Origin: 'https://chzzk.naver.com',
Referer: 'https://chzzk.naver.com/',
};
async function getJson<T>(url: string): Promise<T> {
const res = await fetch(url, {
headers: DEFAULT_HEADERS,
cache: 'no-store',
});
if (!res.ok) {
throw new Error(`치지직 API 오류 (${res.status})`);
}
return (await res.json()) as T;
}
export type ChannelInfo = {
channelId: string;
channelName: string | null;
};
export async function fetchChannelInfo(channelId: string): Promise<ChannelInfo> {
const data = await getJson<{
content?: { channelId?: string; channelName?: string };
}>(`${CHZZK_API}/service/v1/channels/${channelId}`);
return {
channelId: data.content?.channelId || channelId,
channelName: data.content?.channelName ?? null,
};
}
export type LiveChatInfo = {
chatChannelId: string;
liveTitle: string | null;
status: string | null;
};
/** 방송 중일 때 chatChannelId를 얻는다. */
export async function fetchLiveChatInfo(channelId: string): Promise<LiveChatInfo> {
// polling live-status 우선
try {
const status = await getJson<{
content?: { chatChannelId?: string; status?: string; liveTitle?: string };
}>(`${CHZZK_API}/polling/v2/channels/${channelId}/live-status`);
if (status.content?.chatChannelId) {
return {
chatChannelId: status.content.chatChannelId,
liveTitle: status.content.liveTitle ?? null,
status: status.content.status ?? null,
};
}
} catch {
// fallback below
}
const detail = await getJson<{
content?: { chatChannelId?: string; liveTitle?: string; status?: string };
}>(`${CHZZK_API}/service/v3/channels/${channelId}/live-detail`);
if (!detail.content?.chatChannelId) {
throw new Error('방송이 꺼져 있거나 chatChannelId를 찾지 못했습니다.');
}
return {
chatChannelId: detail.content.chatChannelId,
liveTitle: detail.content.liveTitle ?? null,
status: detail.content.status ?? null,
};
}
export async function fetchChatAccessToken(chatChannelId: string): Promise<string> {
const data = await getJson<{
code?: number;
message?: string;
content?: { accessToken?: string };
}>(
`${GAME_API}/v1/chats/access-token?channelId=${encodeURIComponent(chatChannelId)}&chatType=STREAMING`,
);
if (data.code === 42601) {
throw new Error('연령 제한 방송이라 익명 채팅 토큰을 받을 수 없습니다.');
}
const token = data.content?.accessToken;
if (!token) {
throw new Error(data.message || '채팅 accessToken을 받지 못했습니다.');
}
return token;
}
export function chatServerUrl(chatChannelId: string) {
const sum = chatChannelId
.split('')
.map((c) => c.charCodeAt(0))
.reduce((a, b) => a + b, 0);
const serverId = (Math.abs(sum) % 9) + 1;
return `wss://kr-ss${serverId}.chat.naver.com/chat`;
}

505
lib/chzzk/chat-session.ts Normal file
View File

@@ -0,0 +1,505 @@
import WebSocket from 'ws';
import {
chatServerUrl,
fetchChannelInfo,
fetchChatAccessToken,
fetchLiveChatInfo,
} from './channel';
import {
addChatMessage,
addDiceFromDonation,
clearChatFeed,
getRoom,
getRoomViewerCount,
hasSeenDonationKey,
markSeenDonationKey,
notifyRoom,
setRoomMeta,
} from '../rooms/store';
const ChatCmd = {
PING: 0,
PONG: 10000,
CONNECT: 100,
CONNECTED: 10100,
REQUEST_RECENT_CHAT: 5101,
RECENT_CHAT: 15101,
CHAT: 93101,
DONATION: 93102,
} as const;
const ChatType = {
TEXT: 1,
DONATION: 10,
SUBSCRIPTION: 11,
} as const;
type SessionHandle = {
roomId: string;
ws: WebSocket;
chatChannelId: string;
sid: string | null;
pingTimer: ReturnType<typeof setTimeout> | null;
pollTimer: ReturnType<typeof setInterval> | null;
closed: boolean;
/** 이번 연결에서 이미 피드에 넣은 메시지 키 */
seenMessageKeys: Set<string>;
};
const SEEN_KEYS_MAX = 800;
const RECENT_CHAT_COUNT = 50;
const globalForSessions = globalThis as typeof globalThis & {
__drinkmarbleChatSessions?: Map<string, SessionHandle>;
};
function getSessions() {
if (!globalForSessions.__drinkmarbleChatSessions) {
globalForSessions.__drinkmarbleChatSessions = new Map();
}
return globalForSessions.__drinkmarbleChatSessions;
}
function clearPing(handle: SessionHandle) {
if (handle.pingTimer) clearTimeout(handle.pingTimer);
handle.pingTimer = null;
}
function schedulePing(handle: SessionHandle) {
clearPing(handle);
handle.pingTimer = setTimeout(() => {
if (handle.closed || handle.ws.readyState !== WebSocket.OPEN) return;
handle.ws.send(JSON.stringify({ cmd: ChatCmd.PING, ver: '2' }));
schedulePing(handle);
}, 20000);
}
function parseMaybeJson<T>(value: unknown): T | null {
if (value == null) return null;
if (typeof value === 'object') return value as T;
if (typeof value !== 'string') return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
function messageTimeMs(chat: Record<string, unknown>): number | null {
const raw = chat.msgTime ?? chat.messageTime;
const n = typeof raw === 'string' ? Number(raw) : Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function messageKey(
chat: Record<string, unknown>,
kind: 'donation' | 'chat',
): string | null {
const id = chat.msgUid ?? chat.messageId ?? null;
if (id != null && String(id).length > 0) {
return `${kind}:${String(id)}`;
}
const time = messageTimeMs(chat);
const profile = parseMaybeJson<{ nickname?: string; userIdHash?: string }>(
chat.profile,
);
const extras = parseMaybeJson<{ payAmount?: number }>(chat.extras);
const who = profile?.userIdHash || profile?.nickname || '';
const msg = String(chat.msg ?? chat.content ?? '');
if (time == null && !who && !msg) return null;
if (kind === 'donation') {
return `donation:${time ?? 0}:${who}:${Number(extras?.payAmount ?? 0)}:${msg}`;
}
return `chat:${time ?? 0}:${who}:${msg}`;
}
function rememberMessageKey(handle: SessionHandle, key: string | null): boolean {
if (!key) return true;
if (handle.seenMessageKeys.has(key)) return false;
handle.seenMessageKeys.add(key);
if (handle.seenMessageKeys.size > SEEN_KEYS_MAX) {
const oldest = handle.seenMessageKeys.values().next().value;
if (oldest != null) handle.seenMessageKeys.delete(oldest);
}
return true;
}
function handleDonationBody(
roomId: string,
handle: SessionHandle,
chat: Record<string, unknown>,
isRecent: boolean,
): boolean {
const key = messageKey(chat, 'donation');
if (!rememberMessageKey(handle, key)) return false;
const extras = parseMaybeJson<{
payAmount?: number;
donationType?: string;
}>(chat.extras);
const profile = parseMaybeJson<{ nickname?: string }>(chat.profile);
const payAmount = Number(extras?.payAmount ?? 0);
if (!Number.isFinite(payAmount) || payAmount <= 0) return false;
// 히스토리 후원: 피드에만 표시하고, 이미 본 것으로 표시해 재적용 방지
if (isRecent) {
if (key) markSeenDonationKey(roomId, key);
addDiceFromDonation(roomId, {
payAmount,
donatorNickname: profile?.nickname,
donationText: String(chat.msg ?? chat.content ?? ''),
applyDice: false,
});
return true;
}
if (key && hasSeenDonationKey(roomId, key)) {
return false;
}
if (key) markSeenDonationKey(roomId, key);
addDiceFromDonation(roomId, {
payAmount,
donatorNickname: profile?.nickname,
donationText: String(chat.msg ?? chat.content ?? ''),
applyDice: true,
});
return true;
}
function handleChatBody(
roomId: string,
handle: SessionHandle,
chat: Record<string, unknown>,
): boolean {
const key = messageKey(chat, 'chat');
if (!rememberMessageKey(handle, key)) return false;
const profile = parseMaybeJson<{ nickname?: string }>(chat.profile);
const message = String(chat.msg ?? chat.content ?? '').trim();
if (!message) return false;
addChatMessage(roomId, {
nickname: profile?.nickname,
message,
});
return true;
}
function processMessageList(
roomId: string,
handle: SessionHandle,
body: Record<string, unknown> | unknown[] | undefined,
cmd: number,
) {
const isRecent = cmd === ChatCmd.RECENT_CHAT;
const list = Array.isArray(body)
? body
: Array.isArray((body as { messageList?: unknown[] })?.messageList)
? ((body as { messageList: unknown[] }).messageList)
: body
? [body]
: [];
let changed = false;
for (const item of list) {
if (!item || typeof item !== 'object') continue;
const chat = item as Record<string, unknown>;
const type = Number(chat.msgTypeCode ?? chat.messageTypeCode ?? 0);
if (cmd === ChatCmd.DONATION || type === ChatType.DONATION) {
if (handleDonationBody(roomId, handle, chat, isRecent)) changed = true;
} else if (
type === ChatType.TEXT ||
type === ChatType.SUBSCRIPTION ||
!type
) {
if (handleChatBody(roomId, handle, chat)) changed = true;
}
}
// 최근 채팅 50개를 메시지마다 SSE로 보내면 주사위 Canvas가 깨질 수 있어 한 번만 알림
if (changed) notifyRoom(roomId);
}
function requestRecentChat(handle: SessionHandle) {
if (
handle.closed ||
!handle.sid ||
handle.ws.readyState !== WebSocket.OPEN
) {
return;
}
handle.ws.send(
JSON.stringify({
ver: '2',
cmd: ChatCmd.REQUEST_RECENT_CHAT,
svcid: 'game',
cid: handle.chatChannelId,
sid: handle.sid,
tid: 2,
bdy: { recentMessageCount: RECENT_CHAT_COUNT },
}),
);
}
function onSocketMessage(roomId: string, handle: SessionHandle, raw: WebSocket.RawData) {
let json: {
cmd?: number;
bdy?: unknown;
};
try {
json = JSON.parse(String(raw));
} catch {
return;
}
const cmd = json.cmd;
const body = json.bdy as Record<string, unknown> | unknown[] | undefined;
if (cmd === ChatCmd.CONNECTED) {
const sid =
body && !Array.isArray(body) && typeof body.sid === 'string'
? body.sid
: null;
handle.sid = sid;
handle.seenMessageKeys.clear();
clearChatFeed(roomId);
setRoomMeta(roomId, { chatStatus: 'connected', chatError: null });
notifyRoom(roomId);
schedulePing(handle);
requestRecentChat(handle);
return;
}
if (cmd === ChatCmd.PING) {
if (handle.ws.readyState === WebSocket.OPEN) {
handle.ws.send(JSON.stringify({ cmd: ChatCmd.PONG, ver: '2' }));
}
return;
}
if (
cmd === ChatCmd.CHAT ||
cmd === ChatCmd.DONATION ||
cmd === ChatCmd.RECENT_CHAT
) {
processMessageList(roomId, handle, body, cmd ?? 0);
schedulePing(handle);
}
}
async function openSocket(roomId: string, channelId: string): Promise<SessionHandle> {
const live = await fetchLiveChatInfo(channelId);
const accessToken = await fetchChatAccessToken(live.chatChannelId);
const url = chatServerUrl(live.chatChannelId);
setRoomMeta(roomId, {
chatChannelId: live.chatChannelId,
chatStatus: 'connecting',
chatError: null,
});
notifyRoom(roomId);
const ws = new WebSocket(url, {
headers: {
Origin: 'https://chzzk.naver.com',
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
},
});
const handle: SessionHandle = {
roomId,
ws,
chatChannelId: live.chatChannelId,
sid: null,
pingTimer: null,
pollTimer: null,
closed: false,
seenMessageKeys: new Set(),
};
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('채팅 웹소켓 연결 시간 초과'));
}, 12000);
ws.on('open', () => {
ws.send(
JSON.stringify({
ver: '2',
cmd: ChatCmd.CONNECT,
svcid: 'game',
cid: live.chatChannelId,
tid: 1,
bdy: {
uid: null,
devType: 2001,
accTkn: accessToken,
auth: 'READ',
},
}),
);
});
ws.on('message', (data) => {
try {
const json = JSON.parse(String(data)) as { cmd?: number };
if (json.cmd === ChatCmd.CONNECTED) {
clearTimeout(timer);
onSocketMessage(roomId, handle, data);
resolve();
return;
}
} catch {
// fallthrough
}
onSocketMessage(roomId, handle, data);
});
ws.on('error', (err) => {
clearTimeout(timer);
reject(err instanceof Error ? err : new Error('웹소켓 오류'));
});
ws.on('close', () => {
handle.closed = true;
clearPing(handle);
if (handle.pollTimer) clearInterval(handle.pollTimer);
const current = getSessions().get(roomId);
if (current === handle) {
getSessions().delete(roomId);
setRoomMeta(roomId, { chatStatus: 'disconnected' });
notifyRoom(roomId);
}
});
});
// chatChannelId 변경 폴링 (방송 재시작 대응)
handle.pollTimer = setInterval(() => {
void (async () => {
try {
const next = await fetchLiveChatInfo(channelId);
if (next.chatChannelId && next.chatChannelId !== handle.chatChannelId) {
await disconnectRoomChat(roomId);
await connectRoomChat(roomId);
}
} catch {
// ignore poll errors
}
})();
}, 30000);
getSessions().set(roomId, handle);
return handle;
}
export function isRoomChatLive(roomId: string) {
const handle = getSessions().get(roomId);
return Boolean(
handle &&
!handle.closed &&
handle.ws.readyState === WebSocket.OPEN &&
handle.sid,
);
}
const globalForDisconnect = globalThis as typeof globalThis & {
__drinkmarbleChatDisconnectTimers?: Map<string, ReturnType<typeof setTimeout>>;
};
function getDisconnectTimers() {
if (!globalForDisconnect.__drinkmarbleChatDisconnectTimers) {
globalForDisconnect.__drinkmarbleChatDisconnectTimers = new Map();
}
return globalForDisconnect.__drinkmarbleChatDisconnectTimers;
}
/** 빠른 재입장 시 이전 leave가 새 연결을 끊지 않도록 예약 해제 */
export function cancelScheduledDisconnectRoomChat(roomId: string) {
const timers = getDisconnectTimers();
const t = timers.get(roomId);
if (!t) return;
clearTimeout(t);
timers.delete(roomId);
}
/**
* 시청자가 0이 되어도 바로 끊지 않고 잠시 대기.
* 그 사이 재입장하면 채팅 세션을 유지한다.
*/
export function scheduleDisconnectRoomChat(roomId: string, delayMs = 4000) {
cancelScheduledDisconnectRoomChat(roomId);
const timers = getDisconnectTimers();
const t = setTimeout(() => {
timers.delete(roomId);
if (getRoomViewerCount(roomId) > 0) return;
void disconnectRoomChat(roomId);
}, delayMs);
timers.set(roomId, t);
}
/** 살아 있으면 유지(최근 채팅만 재요청), 아니면 재연결 */
export async function ensureRoomChat(roomId: string) {
cancelScheduledDisconnectRoomChat(roomId);
const room = getRoom(roomId);
if (!room) throw new Error('방을 찾을 수 없습니다.');
if (!room.settings.chzzkEnabled) {
throw new Error('이 방은 치지직 연동이 꺼져 있습니다.');
}
if (isRoomChatLive(roomId)) {
const handle = getSessions().get(roomId);
if (handle) requestRecentChat(handle);
setRoomMeta(roomId, { chatStatus: 'connected', chatError: null });
notifyRoom(roomId);
return;
}
await connectRoomChat(roomId);
}
export async function connectRoomChat(roomId: string) {
cancelScheduledDisconnectRoomChat(roomId);
const room = getRoom(roomId);
if (!room) throw new Error('방을 찾을 수 없습니다.');
if (!room.settings.chzzkEnabled) {
throw new Error('이 방은 치지직 연동이 꺼져 있습니다.');
}
const channelId = room.settings.channelId;
if (!channelId) throw new Error('channelId가 없습니다.');
await disconnectRoomChat(roomId);
try {
const info = await fetchChannelInfo(channelId);
setRoomMeta(roomId, { channelName: info.channelName, chatStatus: 'connecting' });
notifyRoom(roomId);
await openSocket(roomId, channelId);
} catch (e) {
const message = e instanceof Error ? e.message : '채팅 연결 실패';
setRoomMeta(roomId, { chatStatus: 'error', chatError: message });
notifyRoom(roomId);
throw e instanceof Error ? e : new Error(message);
}
}
export async function disconnectRoomChat(roomId: string) {
cancelScheduledDisconnectRoomChat(roomId);
const handle = getSessions().get(roomId);
if (!handle) {
setRoomMeta(roomId, { chatStatus: 'disconnected' });
notifyRoom(roomId);
return;
}
handle.closed = true;
clearPing(handle);
if (handle.pollTimer) clearInterval(handle.pollTimer);
getSessions().delete(roomId);
try {
handle.ws.close();
} catch {
// ignore
}
setRoomMeta(roomId, { chatStatus: 'disconnected' });
notifyRoom(roomId);
}

140
lib/rooms/client.ts Normal file
View File

@@ -0,0 +1,140 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
export type PublicRoomState = {
id: string;
settings: {
chzzkEnabled: boolean;
liveUrl: string;
channelId: string | null;
cheesePerDice: number;
multiplyByCheese: boolean;
maxMultiplier: number;
};
diceQueue: number;
channelName: string | null;
chatChannelId: string | null;
chatStatus: 'idle' | 'connecting' | 'connected' | 'error' | 'disconnected';
chatError: string | null;
lastDonations: Array<{
id: string;
at: number;
payAmount: number;
diceAdded: number;
donatorNickname: string;
donationText?: string;
simulated?: boolean;
}>;
chatFeed: Array<{
id: string;
at: number;
nickname: string;
message: string;
kind: 'chat' | 'donation';
payAmount?: number;
}>;
};
export function useRoom(roomId: string | null) {
const [room, setRoom] = useState<PublicRoomState | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!roomId) return;
const clientId =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `c-${Date.now()}-${Math.random().toString(16).slice(2)}`;
void fetch(`/api/rooms/${roomId}`)
.then((r) => r.json())
.then((j) => {
if (j.room) setRoom(j.room as PublicRoomState);
else setError(j.error || '방을 불러오지 못했습니다.');
})
.catch(() => setError('방을 불러오지 못했습니다.'));
const es = new EventSource(
`/api/rooms/${roomId}/events?clientId=${encodeURIComponent(clientId)}`,
);
es.addEventListener('room', (ev) => {
try {
setRoom(JSON.parse((ev as MessageEvent).data) as PublicRoomState);
setError(null);
} catch {
// ignore
}
});
es.onerror = () => {
// keep last state; browser will retry SSE
};
const leave = () => {
try {
const payload = JSON.stringify({ clientId });
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
navigator.sendBeacon(
`/api/rooms/${roomId}/leave`,
new Blob([payload], { type: 'application/json' }),
);
} else {
void fetch(`/api/rooms/${roomId}/leave`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true,
});
}
} catch {
// ignore
}
};
window.addEventListener('pagehide', leave);
return () => {
window.removeEventListener('pagehide', leave);
es.close();
leave();
};
}, [roomId]);
const consumeDice = useCallback(
async (count = 1) => {
if (!roomId) return { ok: true as const };
const res = await fetch(`/api/rooms/${roomId}/consume-dice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count }),
});
const json = await res.json();
if (!res.ok) {
return {
ok: false as const,
error: (json.error as string) || '주사위를 소모할 수 없습니다.',
};
}
setRoom(json.room as PublicRoomState);
return { ok: true as const };
},
[roomId],
);
const simulateDonation = useCallback(
async (payAmount: number) => {
if (!roomId) return;
const res = await fetch(`/api/rooms/${roomId}/simulate-donation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ payAmount }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || '시뮬레이션 실패');
setRoom(json.room as PublicRoomState);
},
[roomId],
);
return { room, error, consumeDice, simulateDonation };
}

353
lib/rooms/store.ts Normal file
View File

@@ -0,0 +1,353 @@
import { randomUUID } from 'crypto';
export type RoomSettings = {
/** 치지직 채팅 연동 사용 */
chzzkEnabled: boolean;
/** 라이브/채널 URL 원문 */
liveUrl: string;
channelId: string | null;
/** 이 원(치즈)당 주사위 1개 */
cheesePerDice: number;
/** true면 floor(금액/기준), false면 기준 이상 시 1개 */
multiplyByCheese: boolean;
/**
* 배수 누적 시 한 번 후원으로 쌓을 수 있는 최대 주사위 수.
* 0 이하면 제한 없음.
*/
maxMultiplier: number;
};
export type DonationLogItem = {
id: string;
at: number;
payAmount: number;
diceAdded: number;
donatorNickname: string;
donationText?: string;
simulated?: boolean;
};
export type ChatFeedItem = {
id: string;
at: number;
nickname: string;
message: string;
kind: 'chat' | 'donation';
/** 후원일 때 치즈(원) */
payAmount?: number;
};
export type RoomRecord = {
id: string;
createdAt: number;
settings: RoomSettings;
diceQueue: number;
channelName: string | null;
chatChannelId: string | null;
chatStatus: 'idle' | 'connecting' | 'connected' | 'error' | 'disconnected';
chatError: string | null;
lastDonations: DonationLogItem[];
chatFeed: ChatFeedItem[];
/** 이미 반영한 치지직 후원 메시지 키 (재연결 시 중복 방지) */
seenDonationKeys: Set<string>;
};
export type PublicRoomState = {
id: string;
settings: RoomSettings;
diceQueue: number;
channelName: string | null;
chatChannelId: string | null;
chatStatus: RoomRecord['chatStatus'];
chatError: string | null;
lastDonations: DonationLogItem[];
chatFeed: ChatFeedItem[];
};
type RoomStore = {
rooms: Map<string, RoomRecord>;
/** 룸별 접속 클라이언트 ID 집합 */
viewers: Map<string, Set<string>>;
};
const globalForRooms = globalThis as typeof globalThis & {
__drinkmarbleChatRooms?: RoomStore;
};
function getStore(): RoomStore {
if (!globalForRooms.__drinkmarbleChatRooms) {
globalForRooms.__drinkmarbleChatRooms = {
rooms: new Map(),
viewers: new Map(),
};
}
if (!globalForRooms.__drinkmarbleChatRooms.viewers) {
globalForRooms.__drinkmarbleChatRooms.viewers = new Map();
}
return globalForRooms.__drinkmarbleChatRooms;
}
export function createRoom(input: {
chzzkEnabled: boolean;
liveUrl?: string;
channelId?: string | null;
cheesePerDice?: number;
multiplyByCheese?: boolean;
maxMultiplier?: number;
}): RoomRecord {
const room: RoomRecord = {
id: randomUUID(),
createdAt: Date.now(),
settings: {
chzzkEnabled: Boolean(input.chzzkEnabled),
liveUrl: input.liveUrl?.trim() || '',
channelId: input.channelId ?? null,
cheesePerDice: Math.max(1, Math.trunc(input.cheesePerDice ?? 1000)),
multiplyByCheese: input.multiplyByCheese !== false,
maxMultiplier: Math.max(0, Math.trunc(input.maxMultiplier ?? 0)),
},
diceQueue: 0,
channelName: null,
chatChannelId: null,
chatStatus: 'idle',
chatError: null,
lastDonations: [],
chatFeed: [],
seenDonationKeys: new Set(),
};
getStore().rooms.set(room.id, room);
return room;
}
export function getRoom(roomId: string) {
return getStore().rooms.get(roomId) ?? null;
}
/** 치지직 channelId로 기존 룸 찾기 (가장 최근 생성된 것) */
export function findRoomByChannelId(channelId: string): RoomRecord | null {
const id = channelId.trim().toLowerCase();
if (!id) return null;
let latest: RoomRecord | null = null;
for (const room of getStore().rooms.values()) {
if (room.settings.channelId?.toLowerCase() !== id) continue;
if (!latest || room.createdAt > latest.createdAt) latest = room;
}
return latest;
}
export function setRoomMeta(
roomId: string,
patch: Partial<
Pick<RoomRecord, 'channelName' | 'chatChannelId' | 'chatStatus' | 'chatError'>
>,
) {
const room = getRoom(roomId);
if (!room) return null;
Object.assign(room, patch);
return room;
}
export function calcDiceFromDonation(payAmount: number, settings: RoomSettings) {
const amount = Math.max(0, Math.trunc(payAmount));
const unit = Math.max(1, settings.cheesePerDice);
if (amount < unit) return 0;
if (!settings.multiplyByCheese) return 1;
let dice = Math.floor(amount / unit);
const max = Math.max(0, Math.trunc(settings.maxMultiplier ?? 0));
if (max > 0) dice = Math.min(dice, max);
return dice;
}
export function hasSeenDonationKey(roomId: string, key: string) {
const room = getRoom(roomId);
if (!room || !key) return false;
if (!room.seenDonationKeys) room.seenDonationKeys = new Set();
return room.seenDonationKeys.has(key);
}
export function markSeenDonationKey(roomId: string, key: string) {
const room = getRoom(roomId);
if (!room || !key) return;
if (!room.seenDonationKeys) room.seenDonationKeys = new Set();
room.seenDonationKeys.add(key);
// 삽입 순서 기준 오래된 키부터 정리
while (room.seenDonationKeys.size > 800) {
const oldest = room.seenDonationKeys.values().next().value;
if (oldest == null) break;
room.seenDonationKeys.delete(oldest);
}
}
export function clearChatFeed(roomId: string) {
const room = getRoom(roomId);
if (!room) return null;
room.chatFeed = [];
return room;
}
export function addDiceFromDonation(
roomId: string,
input: {
payAmount: number;
donatorNickname?: string;
donationText?: string;
simulated?: boolean;
/** false면 피드/로그만 남기고 주사위 큐에는 넣지 않음 (최근 채팅 히스토리용) */
applyDice?: boolean;
},
) {
const room = getRoom(roomId);
if (!room) return null;
const applyDice = input.applyDice !== false;
const diceAdded = applyDice
? calcDiceFromDonation(input.payAmount, room.settings)
: 0;
if (diceAdded > 0) room.diceQueue += diceAdded;
const nickname = input.donatorNickname?.trim() || '익명';
const payAmount = Math.max(0, Math.trunc(input.payAmount));
const log: DonationLogItem = {
id: randomUUID(),
at: Date.now(),
payAmount,
diceAdded,
donatorNickname: nickname,
donationText: input.donationText,
simulated: input.simulated,
};
room.lastDonations = [log, ...room.lastDonations].slice(0, 20);
appendChatFeed(room, {
nickname,
message: input.donationText?.trim() || '치즈 후원',
kind: 'donation',
payAmount,
});
return { room, diceAdded };
}
export function addChatMessage(
roomId: string,
input: { nickname?: string; message: string },
) {
const room = getRoom(roomId);
if (!room) return null;
const message = input.message?.trim();
if (!message) return room;
appendChatFeed(room, {
nickname: input.nickname?.trim() || '익명',
message,
kind: 'chat',
});
return room;
}
function appendChatFeed(
room: RoomRecord,
item: Omit<ChatFeedItem, 'id' | 'at'>,
) {
const entry: ChatFeedItem = {
id: randomUUID(),
at: Date.now(),
...item,
};
room.chatFeed = [...room.chatFeed, entry].slice(-60);
}
export function consumeDice(roomId: string, count = 1) {
const room = getRoom(roomId);
if (!room) return null;
const n = Math.max(1, Math.trunc(count));
if (room.diceQueue < n) return null;
room.diceQueue -= n;
return room;
}
export function toPublicRoom(room: RoomRecord): PublicRoomState {
return {
id: room.id,
settings: { ...room.settings },
diceQueue: room.diceQueue,
channelName: room.channelName,
chatChannelId: room.chatChannelId,
chatStatus: room.chatStatus,
chatError: room.chatError,
lastDonations: room.lastDonations,
chatFeed: room.chatFeed,
};
}
type RoomListener = (room: PublicRoomState) => void;
const globalForListeners = globalThis as typeof globalThis & {
__drinkmarbleChatRoomListeners?: Map<string, Set<RoomListener>>;
};
function getListeners() {
if (!globalForListeners.__drinkmarbleChatRoomListeners) {
globalForListeners.__drinkmarbleChatRoomListeners = new Map();
}
return globalForListeners.__drinkmarbleChatRoomListeners;
}
export function subscribeRoom(roomId: string, listener: RoomListener) {
const map = getListeners();
let set = map.get(roomId);
if (!set) {
set = new Set();
map.set(roomId, set);
}
set.add(listener);
return () => {
set!.delete(listener);
if (set!.size === 0) map.delete(roomId);
};
}
export function notifyRoom(roomId: string) {
const room = getRoom(roomId);
if (!room) return;
const state = toPublicRoom(room);
const set = getListeners().get(roomId);
if (!set) return;
for (const listener of set) {
try {
listener(state);
} catch {
// ignore
}
}
}
export function getRoomViewerCount(roomId: string) {
return getStore().viewers.get(roomId)?.size ?? 0;
}
/** 클라이언트 입장. 같은 clientId면 한 번만 집계 */
export function addRoomViewer(roomId: string, clientId: string) {
const store = getStore();
if (!store.rooms.has(roomId) || !clientId) return 0;
let set = store.viewers.get(roomId);
if (!set) {
set = new Set();
store.viewers.set(roomId, set);
}
set.add(clientId);
return set.size;
}
/**
* 클라이언트 퇴장. 이미 빠진 clientId면 no-op.
* viewer가 0이 되면 emptied=true (채팅 끊어도 됨).
*/
export function removeRoomViewer(roomId: string, clientId: string) {
const store = getStore();
const set = store.viewers.get(roomId);
if (!set || !clientId) {
return { viewers: set?.size ?? 0, emptied: !set || set.size === 0 };
}
set.delete(clientId);
if (set.size === 0) {
store.viewers.delete(roomId);
return { viewers: 0, emptied: true };
}
return { viewers: set.size, emptied: false };
}