progessing on client

This commit is contained in:
2024-12-19 10:18:05 +08:00
parent 31930b362b
commit 7d35fe286d
25 changed files with 1634 additions and 504 deletions

View File

@@ -0,0 +1,175 @@
import { EventModel } from '@/api/api-models'
import { KeepLiveWS } from 'bilibili-live-ws/browser'
export default abstract class BaseDanmakuClient {
constructor() {
this.client = null
}
public client: KeepLiveWS | null
public state: 'padding' | 'connected' | 'connecting' | 'disconnected' =
'padding'
public abstract type: 'openlive' | 'direct'
public eventsAsModel: {
danmaku: ((arg1: EventModel, arg2?: any) => void)[]
gift: ((arg1: EventModel, arg2?: any) => void)[]
sc: ((arg1: EventModel, arg2?: any) => void)[]
guard: ((arg1: EventModel, arg2?: any) => void)[]
all: ((arg1: any) => void)[]
} = {
danmaku: [],
gift: [],
sc: [],
guard: [],
all: []
}
public async Start(): Promise<{ success: boolean; message: string }> {
if (this.state == 'connected') {
return {
success: true,
message: '弹幕客户端已启动'
}
}
if (this.state == 'connecting') {
return {
success: false,
message: '弹幕客户端正在启动'
}
}
this.state = 'connecting'
try {
if (!this.client) {
console.log(`[${this.type}] 正在启动弹幕客户端`)
const result = await this.initClient()
if (result.success) {
this.state = 'connected'
}
return result
} else {
console.warn(`[${this.type}] 弹幕客户端已被启动过`)
this.state = 'connected'
return {
success: false,
message: '弹幕客户端已被启动过'
}
}
} catch (err) {
console.error(err)
return {
success: false,
message: err ? err.toString() : '未知错误'
}
}
}
public Stop() {
if (this.state === 'disconnected') {
return
}
this.state = 'disconnected'
if (this.client) {
console.log(`[${this.type}] 正在停止弹幕客户端`)
this.client.close()
} else {
console.warn(`[${this.type}] 弹幕客户端未被启动, 忽略`)
}
this.eventsAsModel = {
danmaku: [],
gift: [],
sc: [],
guard: [],
all: []
}
}
protected abstract initClient(): Promise<{
success: boolean
message: string
}>
protected async initClientInner(
chatClient: KeepLiveWS
): Promise<{ success: boolean; message: string }> {
let isConnected = false
let isError = false
let errorMsg = ''
chatClient.on('error', (err: any) => {
console.error(err)
isError = true
errorMsg = err
})
chatClient.on('live', () => {
isConnected = true
})
chatClient.on('close', () => {
console.log(`[${this.type}] 弹幕客户端已关闭`)
})
chatClient.on('msg', (cmd) => this.onRawMessage(cmd))
this.client = chatClient
while (!isConnected && !isError) {
await new Promise((resolve) => {
setTimeout(resolve, 1000)
})
}
if (isError) {
this.client.close()
this.client = null
}
return {
success: !isError,
message: errorMsg
}
}
public onRawMessage = (command: any) => {
this.eventsAsModel.all?.forEach((d) => {
d(command)
})
}
public abstract onDanmaku(command: any): void
public abstract onGift(command: any): void
public abstract onSC(command: any): void
public abstract onGuard(command: any): void
public on(
eventName: 'danmaku',
listener: (arg1: EventModel, arg2?: any) => void
): this
public on(
eventName: 'gift',
listener: (arg1: EventModel, arg2?: any) => void
): this
public on(
eventName: 'sc',
listener: (arg1: EventModel, arg2?: any) => void
): this
public on(
eventName: 'guard',
listener: (arg1: EventModel, arg2?: any) => void
): this
public on(eventName: 'all', listener: (arg1: any) => void): this
public on(
eventName: 'danmaku' | 'gift' | 'sc' | 'guard' | 'all',
listener: (...args: any[]) => void
): this {
if (!this.eventsAsModel[eventName]) {
this.eventsAsModel[eventName] = []
}
this.eventsAsModel[eventName].push(listener)
return this
}
public off(
eventName: 'danmaku' | 'gift' | 'sc' | 'guard' | 'all',
listener: (...args: any[]) => void
): this {
if (this.eventsAsModel[eventName]) {
const index = this.eventsAsModel[eventName].indexOf(listener)
if (index > -1) {
this.eventsAsModel[eventName].splice(index, 1)
}
}
return this
}
}

View File

@@ -0,0 +1,65 @@
import { KeepLiveWS } from 'bilibili-live-ws/browser'
import BaseDanmakuClient from './BaseDanmakuClient'
export type DirectClientAuthInfo = {
token: string
roomId: number
tokenUserId: number
buvid: string
}
/** 直播间弹幕客户端, 只能在vtsuru.client环境使用
*
* 未实现除raw事件外的所有事件
*/
export default class DirectClient extends BaseDanmakuClient {
public onDanmaku(command: any): void {
throw new Error('Method not implemented.')
}
public onGift(command: any): void {
throw new Error('Method not implemented.')
}
public onSC(command: any): void {
throw new Error('Method not implemented.')
}
public onGuard(command: any): void {
throw new Error('Method not implemented.')
}
constructor(auth: DirectClientAuthInfo) {
super()
this.authInfo = auth
}
public type = 'direct' as const
public readonly authInfo: DirectClientAuthInfo
protected async initClient(): Promise<{ success: boolean; message: string }> {
if (this.authInfo) {
const chatClient = new KeepLiveWS(this.authInfo.roomId, {
key: this.authInfo.token,
buvid: this.authInfo.buvid,
uid: this.authInfo.tokenUserId,
protover: 3
})
chatClient.on('live', () => {
console.log('[DIRECT] 已连接房间: ' + this.authInfo.roomId)
})
/*chatClient.on('DANMU_MSG', this.onDanmaku)
chatClient.on('SEND_GIFT', this.onGift)
chatClient.on('GUARD_BUY', this.onGuard)
chatClient.on('SUPER_CHAT_MESSAGE', this.onSC)
chatClient.on('msg', (data) => {
this.events.all?.forEach((d) => {
d(data)
})
})*/
return await super.initClientInner(chatClient)
} else {
console.log('[DIRECT] 无法开启场次, 未提供弹幕客户端认证信息')
return {
success: false,
message: '未提供弹幕客户端认证信息'
}
}
}
}

View File

@@ -1,10 +1,284 @@
import { EventDataTypes, EventModel, OpenLiveInfo } from '@/api/api-models'
import { EventDataTypes, OpenLiveInfo } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
// @ts-expect-error 忽略js错误
import ChatClientDirectOpenLive from '@/data/chat/ChatClientDirectOpenLive.js'
import { GuidUtils } from '@/Utils'
import { KeepLiveWS } from 'bilibili-live-ws/browser'
import { clearInterval, setInterval } from 'worker-timers'
import { OPEN_LIVE_API_URL } from './constants'
import { OPEN_LIVE_API_URL } from '../constants'
import BaseDanmakuClient from './BaseDanmakuClient'
export default class OpenLiveClient extends BaseDanmakuClient {
constructor(auth?: AuthInfo) {
super()
this.authInfo = auth
this.events = { danmaku: [], gift: [], sc: [], guard: [], all: [] }
}
public type = 'openlive' as const
private timer: any | undefined
public authInfo: AuthInfo | undefined
public roomAuthInfo: RoomAuthInfo | undefined
public authCode: string | undefined
public events: {
danmaku: ((arg1: DanmakuInfo, arg2?: any) => void)[]
gift: ((arg1: GiftInfo, arg2?: any) => void)[]
sc: ((arg1: SCInfo, arg2?: any) => void)[]
guard: ((arg1: GuardInfo, arg2?: any) => void)[]
all: ((arg1: any) => void)[]
}
public async Start(): Promise<{ success: boolean; message: string }> {
const result = await super.Start()
if (result.success) {
this.timer ??= setInterval(() => {
this.sendHeartbeat()
}, 20 * 1000)
}
return result
}
public Stop() {
super.Stop()
this.events = {
danmaku: [],
gift: [],
sc: [],
guard: [],
all: []
}
}
protected async initClient(): Promise<{ success: boolean; message: string }> {
const auth = await this.getAuthInfo()
if (auth.data) {
const chatClient = new KeepLiveWS(auth.data.anchor_info.room_id, {
authBody: JSON.parse(auth.data.websocket_info.auth_body),
address: auth.data.websocket_info.wss_link[0]
})
chatClient.on('LIVE_OPEN_PLATFORM_DM', (cmd) => this.onDanmaku(cmd))
chatClient.on('LIVE_OPEN_PLATFORM_GIFT', (cmd) => this.onGift(cmd))
chatClient.on('LIVE_OPEN_PLATFORM_GUARD', (cmd) => this.onGuard(cmd))
chatClient.on('LIVE_OPEN_PLATFORM_SC', (cmd) => this.onSC(cmd))
chatClient.on('msg', (data) => {
this.events.all?.forEach((d) => {
d(data)
})
}) // 广播所有事件
chatClient.on('live', () => {
console.log(
`[${this.type}] 已连接房间: ${auth.data?.anchor_info.room_id}`
)
})
return await super.initClientInner(chatClient)
} else {
console.log(`[${this.type}] 无法开启场次: ` + auth.message)
return {
success: false,
message: auth.message
}
}
}
private async getAuthInfo(): Promise<{
data: OpenLiveInfo | null
message: string
}> {
try {
const data = await QueryPostAPI<OpenLiveInfo>(
OPEN_LIVE_API_URL + 'start',
this.authInfo?.Code ? this.authInfo : undefined
)
if (data.code == 200) {
console.log(`[${this.type}] 已获取场次信息`)
return {
data: data.data,
message: ''
}
} else {
return {
data: null,
message: data.message
}
}
} catch (err) {
return {
data: null,
message: err?.toString() || '未知错误'
}
}
}
private sendHeartbeat() {
if (this.state !== 'connected') {
clearInterval(this.timer)
this.timer = undefined
return
}
const query = this.authInfo
? QueryPostAPI<OpenLiveInfo>(
OPEN_LIVE_API_URL + 'heartbeat',
this.authInfo
)
: QueryGetAPI<OpenLiveInfo>(OPEN_LIVE_API_URL + 'heartbeat-internal')
query.then((data) => {
if (data.code != 200) {
console.error(`[${this.type}] 心跳失败, 将重新连接`)
this.client?.close()
this.client = null
this.initClient()
}
})
}
public onDanmaku(command: any) {
const data = command.data as DanmakuInfo
this.events.danmaku?.forEach((d) => {
d(data, command)
})
this.eventsAsModel.danmaku?.forEach((d) => {
d(
{
type: EventDataTypes.Message,
name: data.uname,
uid: data.uid,
msg: data.msg,
price: 0,
num: 0,
time: data.timestamp,
guard_level: data.guard_level,
fans_medal_level: data.fans_medal_level,
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
emoji: data.dm_type == 1 ? data.emoji_img_url : undefined,
uface: data.uface,
open_id: data.open_id,
ouid: data.open_id ?? GuidUtils.numToGuid(data.uid)
},
command
)
})
}
public onGift(command: any) {
const data = command.data as GiftInfo
const price = (data.price * data.gift_num) / 1000
this.events.gift?.forEach((d) => {
d(data, command)
})
this.eventsAsModel.gift?.forEach((d) => {
d(
{
type: EventDataTypes.Gift,
name: data.uname,
uid: data.uid,
msg: data.gift_name,
price: data.paid ? price : -price,
num: data.gift_num,
time: data.timestamp,
guard_level: data.guard_level,
fans_medal_level: data.fans_medal_level,
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
uface: data.uface,
open_id: data.open_id,
ouid: data.open_id ?? GuidUtils.numToGuid(data.uid)
},
command
)
})
}
public onSC(command: any) {
const data = command.data as SCInfo
this.events.sc?.forEach((d) => {
d(data, command)
})
this.eventsAsModel.sc?.forEach((d) => {
d(
{
type: EventDataTypes.SC,
name: data.uname,
uid: data.uid,
msg: data.message,
price: data.rmb,
num: 1,
time: data.timestamp,
guard_level: data.guard_level,
fans_medal_level: data.fans_medal_level,
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
uface: data.uface,
open_id: data.open_id,
ouid: data.open_id ?? GuidUtils.numToGuid(data.uid)
},
command
)
})
}
public onGuard(command: any) {
const data = command.data as GuardInfo
this.events.guard?.forEach((d) => {
d(data, command)
})
this.eventsAsModel.guard?.forEach((d) => {
d(
{
type: EventDataTypes.Guard,
name: data.user_info.uname,
uid: data.user_info.uid,
msg:
data.guard_level == 1
? '总督'
: data.guard_level == 2
? '提督'
: data.guard_level == 3
? '舰长'
: '',
price: 0,
num: data.guard_num,
time: data.timestamp,
guard_level: data.guard_level,
fans_medal_level: data.fans_medal_level,
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
uface: data.user_info.uface,
open_id: data.user_info.open_id,
ouid:
data.user_info.open_id ?? GuidUtils.numToGuid(data.user_info.uid)
},
command
)
})
}
public onEvent(
eventName: 'danmaku',
listener: DanmakuEventsMap['danmaku']
): this
public onEvent(eventName: 'gift', listener: DanmakuEventsMap['gift']): this
public onEvent(eventName: 'sc', listener: DanmakuEventsMap['sc']): this
public onEvent(eventName: 'guard', listener: DanmakuEventsMap['guard']): this
public onEvent(eventName: 'all', listener: (arg1: any) => void): this
public onEvent(
eventName: 'danmaku' | 'gift' | 'sc' | 'guard' | 'all',
listener: (...args: any[]) => void
): this {
if (!this.events[eventName]) {
this.events[eventName] = []
}
this.events[eventName].push(listener)
return this
}
public offEvent(
eventName: 'danmaku' | 'gift' | 'sc' | 'guard' | 'all',
listener: (...args: any[]) => void
): this {
if (this.events[eventName]) {
const index = this.events[eventName].indexOf(listener)
if (index > -1) {
this.events[eventName].splice(index, 1)
}
}
return this
}
}
export interface DanmakuInfo {
room_id: number
@@ -162,386 +436,3 @@ export interface DanmakuEventsMap {
guard: (arg1: GuardInfo, arg2?: any) => void
all: (arg1: any) => void
}
export default class DanmakuClient {
constructor(auth: AuthInfo | null) {
this.authInfo = auth
}
private client: any
private timer: any | undefined
private isStarting = false
public authInfo: AuthInfo | null
public roomAuthInfo: RoomAuthInfo | undefined
public authCode: string | undefined
public isRunning: boolean = false
public events: {
danmaku: ((arg1: DanmakuInfo, arg2?: any) => void)[]
gift: ((arg1: GiftInfo, arg2?: any) => void)[]
sc: ((arg1: SCInfo, arg2?: any) => void)[]
guard: ((arg1: GuardInfo, arg2?: any) => void)[]
all: ((arg1: any) => void)[]
} = {
danmaku: [],
gift: [],
sc: [],
guard: [],
all: []
}
public eventsAsModel: {
danmaku: ((arg1: EventModel, arg2?: any) => void)[]
gift: ((arg1: EventModel, arg2?: any) => void)[]
sc: ((arg1: EventModel, arg2?: any) => void)[]
guard: ((arg1: EventModel, arg2?: any) => void)[]
all: ((arg1: any) => void)[]
} = {
danmaku: [],
gift: [],
sc: [],
guard: [],
all: []
}
public async Start(): Promise<{ success: boolean; message: string }> {
if (this.isRunning) {
return {
success: true,
message: '弹幕客户端已启动'
}
}
if (this.isStarting) {
return {
success: false,
message: '弹幕客户端正在启动'
}
}
this.isStarting = true
try {
if (!this.client) {
console.log('[OPEN-LIVE] 正在启动弹幕客户端')
const result = await this.initClient()
if (result.success) {
this.isRunning = true
this.timer ??= setInterval(() => {
this.sendHeartbeat()
}, 20 * 1000)
}
return result
} else {
console.warn('[OPEN-LIVE] 弹幕客户端已被启动过')
return {
success: false,
message: '弹幕客户端已被启动过'
}
}
} finally {
this.isStarting = false
}
}
public Stop() {
if (!this.isRunning) {
return
}
this.isRunning = false
if (this.client) {
console.log('[OPEN-LIVE] 正在停止弹幕客户端')
this.client.stop()
} else {
console.warn('[OPEN-LIVE] 弹幕客户端未被启动, 忽略')
}
if (this.timer) {
clearInterval(this.timer)
this.timer = undefined
}
this.events = {
danmaku: [],
gift: [],
sc: [],
guard: [],
all: []
}
this.eventsAsModel = {
danmaku: [],
gift: [],
sc: [],
guard: [],
all: []
}
}
private sendHeartbeat() {
if (!this.isRunning) {
clearInterval(this.timer)
this.timer = undefined
return
}
const query = this.authInfo
? QueryPostAPI<OpenLiveInfo>(
OPEN_LIVE_API_URL + 'heartbeat',
this.authInfo
)
: QueryGetAPI<OpenLiveInfo>(OPEN_LIVE_API_URL + 'heartbeat-internal')
query.then((data) => {
if (data.code != 200) {
console.error('[OPEN-LIVE] 心跳失败, 将重新连接')
this.client?.stop()
this.client = null
this.initClient()
}
})
}
public onRawMessage = (command: any) => {
this.eventsAsModel.all?.forEach((d) => {
d(command)
})
this.events.all?.forEach((d) => {
d(command)
})
}
public onDanmaku = (command: any) => {
const data = command.data as DanmakuInfo
this.events.danmaku?.forEach((d) => {
d(data, command)
})
this.eventsAsModel.danmaku?.forEach((d) => {
d(
{
type: EventDataTypes.Message,
name: data.uname,
uid: data.uid,
msg: data.msg,
price: 0,
num: 0,
time: data.timestamp,
guard_level: data.guard_level,
fans_medal_level: data.fans_medal_level,
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
emoji: data.dm_type == 1 ? data.emoji_img_url : undefined,
uface: data.uface,
open_id: data.open_id,
ouid: data.open_id ?? GuidUtils.numToGuid(data.uid)
},
command
)
})
}
public onGift = (command: any) => {
const data = command.data as GiftInfo
const price = (data.price * data.gift_num) / 1000
this.events.gift?.forEach((d) => {
d(data, command)
})
this.eventsAsModel.gift?.forEach((d) => {
d(
{
type: EventDataTypes.Gift,
name: data.uname,
uid: data.uid,
msg: data.gift_name,
price: data.paid ? price : -price,
num: data.gift_num,
time: data.timestamp,
guard_level: data.guard_level,
fans_medal_level: data.fans_medal_level,
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
uface: data.uface,
open_id: data.open_id,
ouid: data.open_id ?? GuidUtils.numToGuid(data.uid)
},
command
)
})
}
public onSC = (command: any) => {
const data = command.data as SCInfo
this.events.sc?.forEach((d) => {
d(data, command)
})
this.eventsAsModel.sc?.forEach((d) => {
d(
{
type: EventDataTypes.SC,
name: data.uname,
uid: data.uid,
msg: data.message,
price: data.rmb,
num: 1,
time: data.timestamp,
guard_level: data.guard_level,
fans_medal_level: data.fans_medal_level,
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
uface: data.uface,
open_id: data.open_id,
ouid: data.open_id ?? GuidUtils.numToGuid(data.uid)
},
command
)
})
}
public onGuard = (command: any) => {
const data = command.data as GuardInfo
this.events.guard?.forEach((d) => {
d(data, command)
})
this.eventsAsModel.guard?.forEach((d) => {
d(
{
type: EventDataTypes.Guard,
name: data.user_info.uname,
uid: data.user_info.uid,
msg:
data.guard_level == 1
? '总督'
: data.guard_level == 2
? '提督'
: data.guard_level == 3
? '舰长'
: '',
price: 0,
num: data.guard_num,
time: data.timestamp,
guard_level: data.guard_level,
fans_medal_level: data.fans_medal_level,
fans_medal_name: data.fans_medal_name,
fans_medal_wearing_status: data.fans_medal_wearing_status,
uface: data.user_info.uface,
open_id: data.user_info.open_id,
ouid:
data.user_info.open_id ?? GuidUtils.numToGuid(data.user_info.uid)
},
command
)
})
}
public on(eventName: 'danmaku', listener: DanmakuEventsMap['danmaku']): this
public on(eventName: 'gift', listener: DanmakuEventsMap['gift']): this
public on(eventName: 'sc', listener: DanmakuEventsMap['sc']): this
public on(eventName: 'guard', listener: DanmakuEventsMap['guard']): this
public on(
eventName: 'danmaku' | 'gift' | 'sc' | 'guard',
listener: (...args: any[]) => void
): this {
if (!this.events[eventName]) {
this.events[eventName] = []
}
this.events[eventName].push(listener)
return this
}
public onEvent(
eventName: 'danmaku',
listener: (arg1: EventModel, arg2?: any) => void
): this
public onEvent(
eventName: 'gift',
listener: (arg1: EventModel, arg2?: any) => void
): this
public onEvent(
eventName: 'sc',
listener: (arg1: EventModel, arg2?: any) => void
): this
public onEvent(
eventName: 'guard',
listener: (arg1: EventModel, arg2?: any) => void
): this
public onEvent(eventName: 'all', listener: (arg1: any) => void): this
public onEvent(
eventName: 'danmaku' | 'gift' | 'sc' | 'guard' | 'all',
listener: (...args: any[]) => void
): this {
if (!this.eventsAsModel[eventName]) {
this.eventsAsModel[eventName] = []
}
this.eventsAsModel[eventName].push(listener)
return this
}
public off(
eventName: 'danmaku' | 'gift' | 'sc' | 'guard',
listener: (...args: any[]) => void
): this {
if (this.events[eventName]) {
const index = this.events[eventName].indexOf(listener)
if (index > -1) {
this.events[eventName].splice(index, 1)
}
}
return this
}
public offEvent(
eventName: 'danmaku' | 'gift' | 'sc' | 'guard',
listener: (...args: any[]) => void
): this {
if (this.eventsAsModel[eventName]) {
const index = this.eventsAsModel[eventName].indexOf(listener)
if (index > -1) {
this.eventsAsModel[eventName].splice(index, 1)
}
}
return this
}
private async initClient(): Promise<{ success: boolean; message: string }> {
const auth = await this.getAuthInfo()
if (auth.data) {
const chatClient = new ChatClientDirectOpenLive(auth.data)
//chatClient.msgHandler = this;
chatClient.CMD_CALLBACK_MAP = this.CMD_CALLBACK_MAP
chatClient.start()
this.roomAuthInfo = auth.data as RoomAuthInfo
this.client = chatClient
console.log('[OPEN-LIVE] 已连接房间: ' + auth.data.anchor_info.room_id)
return {
success: true,
message: ''
}
} else {
console.log('[OPEN-LIVE] 无法开启场次: ' + auth.message)
return {
success: false,
message: auth.message
}
}
}
private async getAuthInfo(): Promise<{
data: OpenLiveInfo | null
message: string
}> {
try {
const data = await QueryPostAPI<OpenLiveInfo>(
OPEN_LIVE_API_URL + 'start',
this.authInfo?.Code ? this.authInfo : undefined
)
if (data.code == 200) {
console.log('[OPEN-LIVE] 已获取场次信息')
return {
data: data.data,
message: ''
}
} else {
return {
data: null,
message: data.message
}
}
} catch (err) {
return {
data: null,
message: err?.toString() || '未知错误'
}
}
}
private CMD_CALLBACK_MAP = {
LIVE_OPEN_PLATFORM_DM: this.onDanmaku.bind(this),
LIVE_OPEN_PLATFORM_SEND_GIFT: this.onGift.bind(this),
LIVE_OPEN_PLATFORM_SUPER_CHAT: this.onSC.bind(this),
LIVE_OPEN_PLATFORM_GUARD: this.onGuard.bind(this),
RAW_MESSAGE: this.onRawMessage.bind(this)
}
}

View File

@@ -1,4 +1,5 @@
import ChatClientOfficialBase, * as base from './ChatClientOfficialBase'
import { processAvatarUrl } from './models'
export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
constructor(authInfo) {
@@ -49,7 +50,7 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
}
data = {
avatarUrl: chat.processAvatarUrl(data.uface),
avatarUrl: processAvatarUrl(data.uface),
timestamp: data.timestamp,
authorName: data.uname,
authorType: authorType,
@@ -79,7 +80,7 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
data = {
id: data.msg_id,
avatarUrl: chat.processAvatarUrl(data.uface),
avatarUrl: processAvatarUrl(data.uface),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: data.price,
@@ -97,7 +98,7 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
let data = command.data
data = {
id: data.msg_id,
avatarUrl: chat.processAvatarUrl(data.user_info.uface),
avatarUrl: processAvatarUrl(data.user_info.uface),
timestamp: data.timestamp,
authorName: data.user_info.uname,
privilegeType: data.guard_level,
@@ -113,7 +114,7 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
let data = command.data
data = {
id: data.message_id.toString(),
avatarUrl: chat.processAvatarUrl(data.uface),
avatarUrl: processAvatarUrl(data.uface),
timestamp: data.start_time,
authorName: data.uname,
price: data.rmb,

View File

@@ -0,0 +1,176 @@
import * as chat from './ChatClientOfficialBase'
import * as chatModels from './models.js'
import * as base from './ChatClientOfficialBase'
import ChatClientOfficialBase from './ChatClientOfficialBase'
export default class ChatClientDirectWeb extends ChatClientOfficialBase {
constructor(roomId) {
super()
this.CMD_CALLBACK_MAP = CMD_CALLBACK_MAP
// 调用initRoom后初始化如果失败使用这里的默认值
this.roomId = roomId
this.roomOwnerUid = -1
this.hostServerList = [
{
host: 'broadcastlv.chat.bilibili.com',
port: 2243,
wss_port: 443,
ws_port: 2244
}
]
this.hostServerToken = null
this.buvid = ''
}
async initRoom() {
let res
try {
res = await (
await fetch('/api/room_info?room_id=' + this.roomId, { method: 'GET' })
).json()
} catch {
return true
}
this.roomId = res.roomId
this.roomOwnerUid = res.ownerUid
if (res.hostServerList.length !== 0) {
this.hostServerList = res.hostServerList
}
this.hostServerToken = res.hostServerToken
this.buvid = res.buvid
return true
}
async onBeforeWsConnect() {
// 重连次数太多则重新init_room保险
let reinitPeriod = Math.max(3, (this.hostServerList || []).length)
if (this.retryCount > 0 && this.retryCount % reinitPeriod === 0) {
this.needInitRoom = true
}
return super.onBeforeWsConnect()
}
getWsUrl() {
let hostServer =
this.hostServerList[this.retryCount % this.hostServerList.length]
return `wss://${hostServer.host}:${hostServer.wss_port}/sub`
}
sendAuth() {
let authParams = {
uid: 0,
roomid: this.roomId,
protover: 3,
platform: 'web',
type: 2,
buvid: this.buvid
}
if (this.hostServerToken !== null) {
authParams.key = this.hostServerToken
}
this.websocket.send(this.makePacket(authParams, base.OP_AUTH))
}
async danmuMsgCallback(command) {
let info = command.info
let roomId, medalLevel
if (info[3]) {
roomId = info[3][3]
medalLevel = info[3][0]
} else {
roomId = medalLevel = 0
}
let uid = info[2][0]
let isAdmin = info[2][2]
let privilegeType = info[7]
let authorType
if (uid === this.roomOwnerUid) {
authorType = 3
} else if (isAdmin) {
authorType = 2
} else if (privilegeType !== 0) {
authorType = 1
} else {
authorType = 0
}
let authorName = info[2][1]
let content = info[1]
let data = new chatModels.AddTextMsg({
avatarUrl: await chat.getAvatarUrl(uid, authorName),
timestamp: info[0][4] / 1000,
authorName: authorName,
authorType: authorType,
content: content,
privilegeType: privilegeType,
isGiftDanmaku:
Boolean(info[0][9]) || chat.isGiftDanmakuByContent(content),
authorLevel: info[4][0],
isNewbie: info[2][5] < 10000,
isMobileVerified: Boolean(info[2][6]),
medalLevel: roomId === this.roomId ? medalLevel : 0,
emoticon: info[0][13].url || null
})
this.msgHandler.onAddText(data)
}
sendGiftCallback(command) {
let data = command.data
let isPaidGift = data.coin_type === 'gold'
data = new chatModels.AddGiftMsg({
avatarUrl: chat.processAvatarUrl(data.face),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: isPaidGift ? data.total_coin : 0,
totalFreeCoin: !isPaidGift ? data.total_coin : 0,
giftName: data.giftName,
num: data.num
})
this.msgHandler.onAddGift(data)
}
async guardBuyCallback(command) {
let data = command.data
data = new chatModels.AddMemberMsg({
avatarUrl: await chat.getAvatarUrl(data.uid, data.username),
timestamp: data.start_time,
authorName: data.username,
privilegeType: data.guard_level
})
this.msgHandler.onAddMember(data)
}
superChatMessageCallback(command) {
let data = command.data
data = new chatModels.AddSuperChatMsg({
id: data.id.toString(),
avatarUrl: chat.processAvatarUrl(data.user_info.face),
timestamp: data.start_time,
authorName: data.user_info.uname,
price: data.price,
content: data.message
})
this.msgHandler.onAddSuperChat(data)
}
superChatMessageDeleteCallback(command) {
let ids = []
for (let id of command.data.ids) {
ids.push(id.toString())
}
let data = new chatModels.DelSuperChatMsg({ ids })
this.msgHandler.onDelSuperChat(data)
}
}
const CMD_CALLBACK_MAP = {
DANMU_MSG: ChatClientDirectWeb.prototype.danmuMsgCallback,
SEND_GIFT: ChatClientDirectWeb.prototype.sendGiftCallback,
GUARD_BUY: ChatClientDirectWeb.prototype.guardBuyCallback,
SUPER_CHAT_MESSAGE: ChatClientDirectWeb.prototype.superChatMessageCallback,
SUPER_CHAT_MESSAGE_DELETE:
ChatClientDirectWeb.prototype.superChatMessageDeleteCallback
}

View File

@@ -0,0 +1,374 @@
import { BrotliDecode } from './brotli_decode'
import {
setInterval,
clearInterval,
setTimeout,
clearTimeout
} from 'worker-timers'
import * as chatModels from '../models'
const HEADER_SIZE = 16
export const WS_BODY_PROTOCOL_VERSION_NORMAL = 0
export const WS_BODY_PROTOCOL_VERSION_HEARTBEAT = 1
export const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2
export const WS_BODY_PROTOCOL_VERSION_BROTLI = 3
export const OP_HANDSHAKE = 0
export const OP_HANDSHAKE_REPLY = 1
export const OP_HEARTBEAT = 2
export const OP_HEARTBEAT_REPLY = 3
export const OP_SEND_MSG = 4
export const OP_SEND_MSG_REPLY = 5
export const OP_DISCONNECT_REPLY = 6
export const OP_AUTH = 7
export const OP_AUTH_REPLY = 8
export const OP_RAW = 9
export const OP_PROTO_READY = 10
export const OP_PROTO_FINISH = 11
export const OP_CHANGE_ROOM = 12
export const OP_CHANGE_ROOM_REPLY = 13
export const OP_REGISTER = 14
export const OP_REGISTER_REPLY = 15
export const OP_UNREGISTER = 16
export const OP_UNREGISTER_REPLY = 17
// B站业务自定义OP
// export const MinBusinessOp = 1000
// export const MaxBusinessOp = 10000
export const AUTH_REPLY_CODE_OK = 0
export const AUTH_REPLY_CODE_TOKEN_ERROR = -101
const HEARTBEAT_INTERVAL = 10 * 1000
const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + (5 * 1000)
let textEncoder = new TextEncoder()
let textDecoder = new TextDecoder()
export default class ChatClientOfficialBase {
constructor() {
this.CMD_CALLBACK_MAP = {}
this.msgHandler = chat.getDefaultMsgHandler()
this.needInitRoom = true
this.websocket = null
this.retryCount = 0
this.totalRetryCount = 0
this.isDestroying = false
this.heartbeatTimerId = null
this.receiveTimeoutTimerId = null
}
start() {
this.wsConnect()
}
stop() {
this.isDestroying = true
if (this.websocket) {
this.websocket.close()
}
}
async initRoom() {
throw new Error('Not implemented')
}
makePacket(data, operation) {
if (typeof data === 'object') {
data = JSON.stringify(data)
}
let bodyArr = textEncoder.encode(data)
let headerBuf = new ArrayBuffer(HEADER_SIZE)
let headerView = new DataView(headerBuf)
headerView.setUint32(0, HEADER_SIZE + bodyArr.byteLength) // pack_len
headerView.setUint16(4, HEADER_SIZE) // raw_header_size
headerView.setUint16(6, 1) // ver
headerView.setUint32(8, operation) // operation
headerView.setUint32(12, 1) // seq_id
// 这里如果直接返回 new Blob([headerBuf, bodyArr])在Chrome抓包会显示空包实际上是有数据的为了调试体验最好还是拷贝一遍
let headerArr = new Uint8Array(headerBuf)
let packetArr = new Uint8Array(bodyArr.length + headerArr.length)
packetArr.set(headerArr)
packetArr.set(bodyArr, headerArr.length)
return packetArr
}
sendAuth() {
throw new Error('Not implemented')
}
addDebugMsg(content) {
this.msgHandler.onDebugMsg(new chatModels.DebugMsg({ content }))
}
async wsConnect() {
if (this.isDestroying) {
return
}
this.addDebugMsg('Connecting')
await this.onBeforeWsConnect()
if (this.isDestroying) {
return
}
this.websocket = new WebSocket(this.getWsUrl())
this.websocket.binaryType = 'arraybuffer'
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onclose = this.onWsClose.bind(this)
this.websocket.onmessage = this.onWsMessage.bind(this)
}
async onBeforeWsConnect() {
if (!this.needInitRoom) {
return
}
let res
try {
res = await this.initRoom()
} catch (e) {
res = false
console.error('initRoom exception:', e)
if (e instanceof chatModels.ChatClientFatalError) {
this.stop()
this.msgHandler.onFatalError(e)
}
}
if (!res) {
setTimeout(() => this.onWsClose(), 0)
throw new Error('initRoom failed')
}
this.needInitRoom = false
}
getWsUrl() {
throw new Error('Not implemented')
}
onWsOpen() {
this.addDebugMsg('Connected and authenticating')
this.sendAuth()
if (this.heartbeatTimerId === null) {
this.heartbeatTimerId = setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL)
}
this.refreshReceiveTimeoutTimer()
}
sendHeartbeat() {
this.websocket.send(this.makePacket({}, OP_HEARTBEAT))
}
refreshReceiveTimeoutTimer() {
if (this.receiveTimeoutTimerId) {
clearTimeout(this.receiveTimeoutTimerId)
}
this.receiveTimeoutTimerId = setTimeout(this.onReceiveTimeout.bind(this), RECEIVE_TIMEOUT)
}
onReceiveTimeout() {
console.warn('接收消息超时')
this.addDebugMsg('Receiving message timed out')
this.discardWebsocket()
}
discardWebsocket() {
if (this.receiveTimeoutTimerId) {
clearTimeout(this.receiveTimeoutTimerId)
this.receiveTimeoutTimerId = null
}
if (this.websocket) {
if (this.websocket.onclose) {
setTimeout(() => this.onWsClose(), 0)
}
// 直接丢弃阻塞的websocket不等onclose回调了
this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null
this.websocket.close()
}
}
onWsClose() {
this.addDebugMsg('Disconnected')
this.websocket = null
if (this.heartbeatTimerId) {
clearInterval(this.heartbeatTimerId)
this.heartbeatTimerId = null
}
if (this.receiveTimeoutTimerId) {
clearTimeout(this.receiveTimeoutTimerId)
this.receiveTimeoutTimerId = null
}
if (this.isDestroying) {
return
}
this.retryCount++
this.totalRetryCount++
console.warn(`掉线重连中 retryCount=${this.retryCount}, totalRetryCount=${this.totalRetryCount}`)
// 防止无限重连的保险措施。30次重连大概会断线500秒应该够了
if (this.totalRetryCount > 30) {
this.stop()
let error = new chatModels.ChatClientFatalError(
chatModels.FATAL_ERROR_TYPE_TOO_MANY_RETRIES, 'The connection has lost too many times'
)
this.msgHandler.onFatalError(error)
return
}
this.delayReconnect()
}
delayReconnect() {
this.addDebugMsg(`Scheduling reconnection. The page is ${document.visibilityState === 'visible' ? 'visible' : 'invisible'}`)
if (document.visibilityState === 'visible') {
setTimeout(this.wsConnect.bind(this), this.getReconnectInterval())
return
}
// 页面不可见就先不重连了,即使重连也会心跳超时
let listener = () => {
if (document.visibilityState !== 'visible') {
return
}
document.removeEventListener('visibilitychange', listener)
this.wsConnect()
}
document.addEventListener('visibilitychange', listener)
}
getReconnectInterval() {
// 不用retryCount了防止意外的连接成功导致retryCount重置
let interval = Math.min(1000 + ((this.totalRetryCount - 1) * 2000), 20 * 1000)
// 加上随机延迟,防止同时请求导致雪崩
interval += Math.random() * 3000
return interval
}
onWsMessage(event) {
if (!(event.data instanceof ArrayBuffer)) {
console.warn('未知的websocket消息类型data=', event.data)
return
}
let data = new Uint8Array(event.data)
this.parseWsMessage(data)
// 至少成功处理1条消息
this.retryCount = 0
}
parseWsMessage(data) {
let offset = 0
let dataView = new DataView(data.buffer)
let packLen = dataView.getUint32(0)
let rawHeaderSize = dataView.getUint16(4)
// let ver = dataView.getUint16(6)
let operation = dataView.getUint32(8)
// let seqId = dataView.getUint32(12)
switch (operation) {
case OP_AUTH_REPLY:
case OP_SEND_MSG_REPLY: {
// 业务消息,可能有多个包一起发,需要分包
while (true) { // eslint-disable-line no-constant-condition
let body = new Uint8Array(data.buffer, offset + rawHeaderSize, packLen - rawHeaderSize)
this.parseBusinessMessage(dataView, body)
offset += packLen
if (offset >= data.byteLength) {
break
}
dataView = new DataView(data.buffer, offset)
packLen = dataView.getUint32(0)
rawHeaderSize = dataView.getUint16(4)
}
break
}
case OP_HEARTBEAT_REPLY: {
// 服务器心跳包,包含人气值,这里没用
this.refreshReceiveTimeoutTimer()
break
}
default: {
// 未知消息
let body = new Uint8Array(data.buffer, offset + rawHeaderSize, packLen - rawHeaderSize)
console.warn('未知包类型operation=', operation, dataView, body)
break
}
}
}
parseBusinessMessage(dataView, body) {
let ver = dataView.getUint16(6)
let operation = dataView.getUint32(8)
switch (operation) {
case OP_SEND_MSG_REPLY: {
// 业务消息
if (ver == WS_BODY_PROTOCOL_VERSION_BROTLI) {
// 压缩过的先解压
body = BrotliDecode(body)
this.parseWsMessage(body)
} /*else if (ver == WS_BODY_PROTOCOL_VERSION_DEFLATE) {
// web端已经不用zlib压缩了但是开放平台会用
body = inflate(body)
this.parseWsMessage(body)
} */else {
// 没压缩过的直接反序列化
if (body.length !== 0) {
try {
body = JSON.parse(textDecoder.decode(body))
this.handlerCommand(body)
} catch (e) {
console.error('body=', body)
throw e
}
}
}
break
}
case OP_AUTH_REPLY: {
// 认证响应
body = JSON.parse(textDecoder.decode(body))
if (body.code !== AUTH_REPLY_CODE_OK) {
console.error('认证响应错误body=', body)
this.needInitRoom = true
this.discardWebsocket()
throw new Error('认证响应错误')
}
this.sendHeartbeat()
break
}
default: {
// 未知消息
console.warn('未知包类型operation=', operation, dataView, body)
break
}
}
}
handlerCommand(command) {
let cmd = command.cmd || ''
let pos = cmd.indexOf(':')
if (pos != -1) {
cmd = cmd.substr(0, pos)
}
let callback = this.CMD_CALLBACK_MAP[cmd]
if (callback) {
callback.call(this, command)
}
}
}

150
src/data/chat/models.js Normal file
View File

@@ -0,0 +1,150 @@
import { getUuid4Hex } from '../../views/obs/blivechat/utils'
import * as constants from '../../views/obs/blivechat/constants'
export function getDefaultMsgHandler() {
let dummyFunc = () => {}
return {
onAddText: dummyFunc,
onAddGift: dummyFunc,
onAddMember: dummyFunc,
onAddSuperChat: dummyFunc,
onDelSuperChat: dummyFunc,
onUpdateTranslation: dummyFunc,
onFatalError: dummyFunc,
onDebugMsg: dummyFunc
}
}
export const DEFAULT_AVATAR_URL =
'https://i0.hdslb.com/bfs/face/member/noface.jpg@64w_64h'
export class AddTextMsg {
constructor({
avatarUrl = DEFAULT_AVATAR_URL,
timestamp = new Date().getTime() / 1000,
authorName = '',
authorType = constants.AUTHOR_TYPE_NORMAL,
content = '',
privilegeType = 0,
isGiftDanmaku = false,
authorLevel = 1,
isNewbie = false,
isMobileVerified = true,
medalLevel = 0,
id = getUuid4Hex(),
translation = '',
emoticon = null
} = {}) {
this.avatarUrl = avatarUrl
this.timestamp = timestamp
this.authorName = authorName
this.authorType = authorType
this.content = content
this.privilegeType = privilegeType
this.isGiftDanmaku = isGiftDanmaku
this.authorLevel = authorLevel
this.isNewbie = isNewbie
this.isMobileVerified = isMobileVerified
this.medalLevel = medalLevel
this.id = id
this.translation = translation
this.emoticon = emoticon
}
}
export class AddGiftMsg {
constructor({
id = getUuid4Hex(),
avatarUrl = DEFAULT_AVATAR_URL,
timestamp = new Date().getTime() / 1000,
authorName = '',
totalCoin = 0,
totalFreeCoin = 0,
giftName = '',
num = 1
} = {}) {
this.id = id
this.avatarUrl = avatarUrl
this.timestamp = timestamp
this.authorName = authorName
this.totalCoin = totalCoin
this.totalFreeCoin = totalFreeCoin
this.giftName = giftName
this.num = num
}
}
export class AddMemberMsg {
constructor({
id = getUuid4Hex(),
avatarUrl = DEFAULT_AVATAR_URL,
timestamp = new Date().getTime() / 1000,
authorName = '',
privilegeType = 1
} = {}) {
this.id = id
this.avatarUrl = avatarUrl
this.timestamp = timestamp
this.authorName = authorName
this.privilegeType = privilegeType
}
}
export class AddSuperChatMsg {
constructor({
id = getUuid4Hex(),
avatarUrl = DEFAULT_AVATAR_URL,
timestamp = new Date().getTime() / 1000,
authorName = '',
price = 0,
content = '',
translation = ''
} = {}) {
this.id = id
this.avatarUrl = avatarUrl
this.timestamp = timestamp
this.authorName = authorName
this.price = price
this.content = content
this.translation = translation
}
}
export class DelSuperChatMsg {
constructor({ ids = [] } = {}) {
this.ids = ids
}
}
export class UpdateTranslationMsg {
constructor({ id = getUuid4Hex(), translation = '' } = {}) {
this.id = id
this.translation = translation
}
}
export const FATAL_ERROR_TYPE_AUTH_CODE_ERROR = 1
export const FATAL_ERROR_TYPE_TOO_MANY_RETRIES = 2
export const FATAL_ERROR_TYPE_TOO_MANY_CONNECTIONS = 3
export class ChatClientFatalError extends Error {
constructor(type, message) {
super(message)
this.type = type
}
}
export class DebugMsg {
constructor({ content = '' } = {}) {
this.content = content
}
}
export function processAvatarUrl(avatarUrl) {
// 去掉协议兼容HTTP、HTTPS
let m = avatarUrl.match(/(?:https?:)?(.*)/)
if (m) {
avatarUrl = m[1]
}
return avatarUrl
}