diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb2260b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config/* +dist +log/* \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..01cede9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "deno.enable": true, + "deno.lint": true, + "deno.unstable": false +} \ No newline at end of file diff --git a/src/APIServer.ts b/src/APIServer.ts new file mode 100644 index 0000000..48b6e06 --- /dev/null +++ b/src/APIServer.ts @@ -0,0 +1,60 @@ +import { EventEmitter, WebSocketClient, WebSocketServer } from "./deps.ts"; +import { sendDanmaku } from './SendDanmaku.ts' +import config from "./config.ts"; + +interface Message { + cmd: string; + // deno-lint-ignore no-explicit-any + data: any; +} + +const server = new WebSocketServer(config.api.port); +const authedClientSet: Set = new Set(); + +class APIMsgHandler extends EventEmitter { + emit( + eventName: string | symbol, + socket: WebSocketClient, + ...args: unknown[] + ): boolean { + if (eventName === "AUTH") { + if (args[0] === config.api.token) { + authedClientSet.add(socket); + socket.send(JSON.stringify({ cmd: "AUTH", data: "AUTHED" })); + return true; + } else { + socket.send(JSON.stringify({ cmd: "AUTH", data: "FAILED" })); + return true; + } + } + if (authedClientSet.has(socket)) { + super.emit.apply(this, [eventName, socket, args]); + return true; + } + return false; + } +} + +const serverEventEmitter = new APIMsgHandler(); + +serverEventEmitter.on("SEND", (_socket: WebSocketClient, data: string) => { + sendDanmaku({ + msg: data, + }); +}); + +serverEventEmitter.on("ROOMID", (socket: WebSocket) => { + socket.send(JSON.stringify({ + cmd: "ROOMID", + data: config.room_id, + })); +}); + +server.on("connection", (client: WebSocketClient) => { + client.on("message", (data: string) => { + const msg: Message = JSON.parse(data); + serverEventEmitter.emit(msg.cmd, client, msg.data); + }); +}); + +export { server }; diff --git a/src/DanmakuReceiver.ts b/src/DanmakuReceiver.ts new file mode 100644 index 0000000..6497cbb --- /dev/null +++ b/src/DanmakuReceiver.ts @@ -0,0 +1,140 @@ +import { brotli, EventEmitter } from "./deps.ts"; +import config from "./config.ts"; +import { printLog } from "./utils/printLog.ts"; +import { server as apiServer } from './APIServer.ts' + +enum DANMAKU_PROTOCOL { + JSON = 0, + HEARTBEAT, + ZIP, + BROTLI, +} + +enum DANMAKU_TYPE { + HEARTBEAT = 2, + HEARTBEAT_REPLY = 3, + DATA = 5, + AUTH = 7, + AUTH_REPLY = 8, +} + +const cookie = + `buvid3=${config.verify.buvid3};SESSDATA=${config.verify.sessdata};bili_jct=${config.verify.csrf}`; +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8"); + +export class DanmakuReceiver extends EventEmitter { + private roomId: number; + private ws: WebSocket | null = null; + constructor(roomId: number) { + super(); + this.roomId = roomId; + } + public async connect() { + const roomConfig = await (await fetch( + `https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id=${this.roomId}&platform=pc&player=web`, + { + headers: { + cookie, + "user-agent": + "Mozilla/5.0 (X11 Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36", + host: "api.live.bilibili.com", + }, + }, + )).json(); + this.ws = new WebSocket( + `wss://${roomConfig.data.host_server_list[0].host}:${ + roomConfig.data.host_server_list[0].wss_port + }/sub`, + ); + this.ws.onopen = () => { + const payload = JSON.stringify({ + roomid: this.roomId, + protover: 3, + platform: "web", + uid: config.verify.uid, + key: roomConfig.data.token, + }); + this.ws!.send(this.generatePacket( + 1, + 7, + payload, + )); + this.ws!.onmessage = this.danmakuProcesser.bind(this); + }; + this.ws.onclose = () => { + this.emit("closed"); + }; + } + private generatePacket( + protocol: number, + type: number, + payload: string, + ): ArrayBuffer { + const payloadEncoded = encoder.encode(payload); + const packetLength = 16 + payloadEncoded.length; + const packet = new ArrayBuffer(packetLength); + const packetArray = new Uint8Array(packet); + const packetView = new DataView(packet); + packetView.setInt32(0, packetLength); // 总长度 + packetView.setInt16(4, 16); // 头长度 + packetView.setUint16(6, protocol); // 协议类型 + packetView.setUint32(8, type); // 包类型 + packetView.setUint32(12, 1); // 一个常数 + packetArray.set(payloadEncoded, 16); //写入负载 + return packet; + } + private async danmakuProcesser(ev: MessageEvent) { + // 弹幕事件处理 + const msgPacket = await ev.data.arrayBuffer() + const msgArray = new Uint8Array(msgPacket); + const msg = new DataView(msgPacket); + const packetProtocol = msg.getInt16(6); + const packetType = msg.getInt32(8); + const packetPayload: Uint8Array = msgArray.slice(16); + let jsonData; + switch (packetType) { + case DANMAKU_TYPE.HEARTBEAT_REPLY: + // 心跳包,不做处理 + break; + case DANMAKU_TYPE.AUTH_REPLY: + printLog("通过认证"); + // 认证通过,每30秒发一次心跳包 + setInterval(() => { + const heartbeatPayload = "陈睿你妈死了"; + if (this.ws) { + this.ws.send(this.generatePacket(1, 2, heartbeatPayload)); + } + }, 30000); + this.emit("connected"); + break; + case DANMAKU_TYPE.DATA: + switch (packetProtocol) { + case DANMAKU_PROTOCOL.JSON: + // 这些数据大都没用,但还是留着吧 + jsonData = JSON.parse(decoder.decode(packetPayload)); + this.emit(jsonData.cmd, jsonData.data); + break; + case DANMAKU_PROTOCOL.BROTLI: { + const resultRaw = brotli.decompress(packetPayload); + const result = new DataView(resultRaw.buffer); + let offset = 0; + while (offset < resultRaw.length) { + const length = result.getUint32(offset); + const packetData = resultRaw.slice(offset + 16, offset + length); + const data = JSON.parse(decoder.decode(packetData)); + const cmd = data.cmd.split(":")[0]; + this.emit(cmd, data.info || data.data); + apiServer.clients.forEach((client) => { + client.send(JSON.stringify({ cmd, data: data.info || data.data })) + }) + offset += length; + } + } + } + break; + default: + printLog("什么鬼,没见过这种包"); + } + } +} diff --git a/src/SendDanmaku.ts b/src/SendDanmaku.ts new file mode 100644 index 0000000..3e944cf --- /dev/null +++ b/src/SendDanmaku.ts @@ -0,0 +1,60 @@ +import config from "./config.ts"; + +const cookie = + `buvid3=${config.verify.buvid3}; SESSDATA=${config.verify.sessdata}; bili_jct=${config.verify.csrf};`; + +export interface DanmakuStruct { + color?: number; + bubble?: number; + msg: string; + mode?: number; + fontsize?: number; + rnd?: number; + roomid?: number; + csrf?: string; + csrf_token?: string; +} + +export function sendDanmaku(danmaku: DanmakuStruct) { + if (danmaku.msg.length > 19) { + sendDanmaku({ + msg: danmaku.msg.slice(0, 15), + }); + setTimeout(() => { + sendDanmaku({ + msg: danmaku.msg.slice(15, danmaku.msg.length), + }); + }, 2000); + return; + } + danmaku.rnd = new Date().getTime(); + if (!danmaku.color) { + danmaku.color = 5816798; + } + if (!danmaku.bubble) { + danmaku.bubble = 0; + } + if (!danmaku.mode) { + danmaku.mode = 1; + } + if (!danmaku.fontsize) { + danmaku.fontsize = 24; + } + danmaku.roomid = config.room_id as number; + danmaku.csrf = danmaku.csrf_token = config.verify.csrf; + const data = new FormData(); + for (const k in danmaku) { + data.append(k, danmaku[k as keyof DanmakuStruct]!.toString()); + } + fetch("https://api.live.bilibili.com/msg/send", { + method: 'POST', + body: data, + headers: { + cookie: cookie, + "user-agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36", + host: "api.live.bilibili.com", + "Referer": "https://live.bilibili.com", + } + }); +} diff --git a/src/Text.ts b/src/Text.ts new file mode 100644 index 0000000..86a364c --- /dev/null +++ b/src/Text.ts @@ -0,0 +1,14 @@ +const encoder = new TextEncoder() +const decoder = new TextDecoder('utf-8') +const Encoding = { + UTF8: { + getBytes (str: string) { + return encoder.encode(str) + }, + getText (buffer: Uint8Array) { + return decoder.decode(buffer) + } + } +} + +export { Encoding } \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6e766d9 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,35 @@ +interface DanmakuTemplate { + live_start: string + live_end: string + gift: string + gift_total: string + guard: string + sc: string + advertisement: string +} + +interface Credential { + sessdata: string + csrf: string + buvid3: string + uid: number +} + +interface api_config { + token: string + port: number +} + +interface ConfigStruct { + room_id: number + verify: Credential + danmakus: DanmakuTemplate + cold_down_time: number + advertiseing_cold_down: number + api: api_config +} + +const decoder = new TextDecoder('utf-8') +const config: ConfigStruct = JSON.parse(decoder.decode(Deno.readFileSync(`config/${Deno.args[0]}`))); + +export default config; diff --git a/src/danmakuCallbacks.ts b/src/danmakuCallbacks.ts new file mode 100644 index 0000000..68066d9 --- /dev/null +++ b/src/danmakuCallbacks.ts @@ -0,0 +1,69 @@ +// deno-lint-ignore-file +/* eslint-disable @typescript-eslint/no-explicit-any */ +import config from './config.ts' +import { sendDanmaku } from './SendDanmaku.ts' +import { Encoding } from './Text.ts' +import { getTimeString, FormatString } from './utils/mod.ts' +let skipCount = 0 +let logFile = Deno.openSync(`./log/${getTimeString()}-${config.room_id}.log`, { + create: true, + write: true +}) +const thanksColdDownSet = new Set() + + +export function receiveGift(data: any) { + if(thanksColdDownSet.has(data.uname)){ + return + } + logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} ${data.uname} 投喂了${data.super_gift_num}个 ${data.giftName} 价值${data.price / 1000 * data.super_gift_num}元\n`)) + sendDanmaku({ + msg: FormatString(config.danmakus.gift, { name: data.uname, gift: data.giftName }) + }) + thanksColdDownSet.add(data.uname) + setTimeout(() => {thanksColdDownSet.delete(data.uname)}, config.cold_down_time) +} + +export function onTotalGift(data: any) { + logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} ${data.uname}投喂了${data.total_num}个${data.gift_name}\n`)) + sendDanmaku({ + msg: FormatString(config.danmakus.gift_total, { name: data.uname, gift: data.gift_name, count: data.total_num }) + }) +} + +export function receiveDanmaku(data: any) { + logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} ${data[2][1]}:${data[2][0]} ${data[1]}\n`)) +} + +export function onLiveStart() { + if(skipCount != 1){ + skipCount ++ + return + } + skipCount = 0 + logFile.close() + sendDanmaku({ msg: config.danmakus.live_start }) + logFile = Deno.openSync(`./log/${getTimeString()}-${config.room_id}.log`, { + create: true + }) + logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} 直播开始\n`)) +} + +export function onLiveEnd() { + logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} 直播结束\n`)) + sendDanmaku({ msg: config.danmakus.live_end }) +} + +export function onGraud(data: any) { + logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} ${data.username}:${data.uid} 购买了 ${data.gift_name}\n`)) + sendDanmaku({ + msg: FormatString(config.danmakus.guard, { type: data.gift_name, name: data.username }) + }) +} + +export function onSuperChat(data: any) { + logFile.writeSync(Encoding.UTF8.getBytes(`${getTimeString()} ${data.user_info.uname}发送了SC 价格${data.price}\n`)) + sendDanmaku({ + msg: FormatString(config.danmakus.sc, { name: data.user_info.uname }) + }) +} diff --git a/src/deps.ts b/src/deps.ts new file mode 100644 index 0000000..7ced468 --- /dev/null +++ b/src/deps.ts @@ -0,0 +1,7 @@ +import * as events from 'https://deno.land/x/events@v1.0.0/mod.ts' + +const EventEmitter = events.default +export { EventEmitter } +export * as brotli from 'https://deno.land/x/brotli@v0.1.4/mod.ts' +export { WebSocketServer } from "https://deno.land/x/websocket@v0.1.4/mod.ts"; +export type { WebSocketClient } from "https://deno.land/x/websocket@v0.1.4/mod.ts"; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..2909f10 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,20 @@ +import config from './config.ts'; +import { DanmakuReceiver } from './DanmakuReceiver.ts'; +import { onGraud, onLiveEnd, onLiveStart, onSuperChat, onTotalGift, receiveDanmaku, receiveGift } from './danmakuCallbacks.ts'; +import { printLog } from './utils/mod.ts'; + +const danmakuReceiver = new DanmakuReceiver(config.room_id); +danmakuReceiver.on('connected', () => { + printLog('连接成功'); +}); + +danmakuReceiver.on('closed', () => danmakuReceiver.connect()); +danmakuReceiver.on('SEND_GIFT', receiveGift); +danmakuReceiver.on('LIVE', onLiveStart); +danmakuReceiver.on('PREPARING', onLiveEnd); +danmakuReceiver.on('DANMU_MSG', receiveDanmaku); +danmakuReceiver.on('COMBO_SEND', onTotalGift); +danmakuReceiver.on('GUARD_BUY', onGraud); +danmakuReceiver.on('SUPER_CHAT_MESSAGE', onSuperChat); + +danmakuReceiver.connect(); diff --git a/src/utils/FormatString.ts b/src/utils/FormatString.ts new file mode 100644 index 0000000..4813c98 --- /dev/null +++ b/src/utils/FormatString.ts @@ -0,0 +1,8 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function FormatString(str: string, args: any): string { + let result: string = str; + for(const arg in args) { + result = result.replace(new RegExp(`{${arg}}`, 'g'), args[arg]) + } + return result; +} \ No newline at end of file diff --git a/src/utils/getTimeString.ts b/src/utils/getTimeString.ts new file mode 100644 index 0000000..9f41473 --- /dev/null +++ b/src/utils/getTimeString.ts @@ -0,0 +1,11 @@ +export function getTimeString(): string { + const time = new Date() + const year = time.getFullYear() + const month = time.getMonth() + 1 + const day = time.getDate() + const hour = time.getHours() + const minutes = time.getMinutes() + const second = time.getSeconds() + + return `${year}-${month}-${day}-${hour}-${minutes}-${second}` + } \ No newline at end of file diff --git a/src/utils/mod.ts b/src/utils/mod.ts new file mode 100644 index 0000000..6b16cbc --- /dev/null +++ b/src/utils/mod.ts @@ -0,0 +1,3 @@ +export { getTimeString } from './getTimeString.ts' +export { printLog } from './printLog.ts' +export { FormatString } from './FormatString.ts' \ No newline at end of file diff --git a/src/utils/printLog.ts b/src/utils/printLog.ts new file mode 100644 index 0000000..6f38ae5 --- /dev/null +++ b/src/utils/printLog.ts @@ -0,0 +1,4 @@ +import { getTimeString } from './getTimeString.ts' +export function printLog(msg: unknown) { + console.log(`[${getTimeString()}] ${msg}`) +} \ No newline at end of file