import { Plugin, Configurable, EventListener, InjectService } from '@squeebot/core/lib/plugin'; import { logger } from '@squeebot/core/lib/core'; import { EMessageType, Formatter, IMessage, IMessageTarget, Protocol } from '@squeebot/core/lib/types'; import util from 'util'; import crypto from 'crypto'; import net, { Socket } from 'net'; const PROTOCOL_VERSION = '1.6.7'; interface SyncplayFeatures { isolateRooms?: boolean; readiness?: boolean; managedRooms?: boolean; chat?: boolean; maxChatMessageLength?: number; maxUsernameLength?: number; maxRoomNameLength?: number; maxFilenameLength?: number; } interface SyncplayHello { username?: string; room?: { name: string; }; version?: string; realversion?: string; motd?: string; features?: SyncplayFeatures; } interface SyncplayUser { position: number; file: any; controller: boolean; isReady: boolean; features: { sharedPlaylists: boolean; chat: boolean; featureList: boolean; readiness: boolean; managedRooms: boolean; }; } class SyncplayMessage implements IMessage { public time: Date = new Date(); public resolved = false; public direct = false; public guest = false; constructor( public type: EMessageType, public data: any, public source: Protocol, public sender: IMessageTarget, public target?: IMessageTarget) {} public get fullSenderID(): string { return this.source.fullName + '/' + this.sender.id; } public get fullRoomID(): string { if (!this.target) { return this.source.fullName; } return this.source.fullName + '/' + this.target.id; } public get text(): string { return this.data; } public resolve(...args: any[]): void { this.resolved = true; this.source.resolve(this, ...args); } public reject(error: Error): void { this.resolved = true; this.source.resolve(this, error.message); } public mention(user: IMessageTarget): string { return user.name; } } class SyncplayProtocol extends Protocol { public format: Formatter = new Formatter(); public type = 'SyncplayProtocol'; public users: {[key: string]: SyncplayUser} = {}; public info: SyncplayHello = {}; public socket: Socket | null = null; public file = ''; private fetchList(): void { if (!this.socket || !this.running) { return; } this.write({ List: null }); setTimeout(() => this.fetchList(), 5000); } private handleServerLine(obj: any, raw: any): void { // Save information from hello if (obj.Hello) { this.info = obj.Hello; this.fetchList(); return; } // Return pings if (obj.State && obj.State.ping) { this.write({ State: { ignoringOnTheFly: obj.State.ignoringOnTheFly, ping: { clientRtt: 0, clientLatencyCalculation: Date.now() / 1000, latencyCalculation: obj.State.ping.latencyCalculation }, playstate: { paused: obj.State.playstate.paused, position: obj.State.playstate.position } } }); return; } // Handle chat if (obj.Chat && obj.Chat.message && obj.Chat.username !== this.config.syncplay.name) { const newMessage = new SyncplayMessage( EMessageType.message, obj.Chat.message, this, { name: obj.Chat.username, id: obj.Chat.username }, { name: this.config.syncplay.room, id: this.config.syncplay.room }); this.plugin.stream.emitTo('channel', 'message', newMessage); return; } // Set file if (obj.Set && obj.Set.file) { this.file = obj.Set.file; this.write({ Set: { file: obj.Set.file } }); return; } // List users const room = this.config.syncplay.room; if (obj.List && obj.List[room]) { this.users = obj.List[room]; return; } // Forward errors if (obj.Error) { this.emit('error', new Error(obj.Error.message)); return; } } public start(...args: any[]): void { this.me = { name: this.config.syncplay.name, id: this.config.syncplay.name }; const opts = { host: this.config.syncplay.host, port: this.config.syncplay.port }; let password: string | null = this.config.syncplay.password; if (password != null && password !== '') { password = crypto.createHash('md5').update(password).digest('hex'); } try { this.socket = net.connect(opts, () => { this.write({ Hello: { username: this.config.syncplay.name, password, room: { name: this.config.syncplay.room }, version: PROTOCOL_VERSION } }); }); } catch(err: any) { this.emit('error', err); this.stop(true); return; } let buffer: any = ''; this.socket.on('data', (chunk) => { buffer += chunk; const data = buffer.split('\r\n'); buffer = data.pop(); data.forEach((line: string) => { // Parse the line const parsed = JSON.parse(line); // Handle the line this.handleServerLine(parsed, line); }); }); this.socket.on('close', (data) => this.stop(true)); this.socket.on('error', (data: Error) => { this.emit('error', data); this.stop(true); }); this.running = true; } write(obj: any): void { if (!this.socket || !this.running) { return; } const toSend = JSON.stringify(obj); this.socket.write(toSend + '\r\n'); } public stop(force = false): void { if (!this.running) { return; } this.running = false; this.stopped = true; if (this.socket) { this.socket.destroy(); this.socket = null; } if (force) { this.failed = true; } this.emit('stopped'); } public resolve(msg: any, ...data: any[]): void { this.sendTo(`syncplay/${this.name}/${this.config.syncplay.room}`, ...data); } public async sendTo(target: string, ...data: any[]): Promise { let response = util.format(data[0], ...data.slice(1)); if (!response || !this.socket) { return false; } const rxSplit = target.split('/'); if (rxSplit.length !== 3 || rxSplit[0] !== 'syncplay' || rxSplit[1] !== this.name) { return false; } if (Array.isArray(data[0])) { try { response = this.format.compose(data[0]); } catch (e: any) { logger.error('[%s] Failed to compose message:', this.fullName, e.message); return false; } } // Send lines and max length exceeding messages separately if (!this.info || !this.info.features) { return false; } const maxlen = this.info.features.maxChatMessageLength as number; const splitup = response.split('\n'); const toSend = []; for (const line of splitup) { for (let j = 0, len = line.length; j < len; j += maxlen) { toSend.push(line.substring(j, j + maxlen)); } } toSend.forEach((line: string) => this.write({ Chat: line })); return true; } } /* { name: 'syncplay', syncplay: { name: 'Squeebot', host: 'syncplay.pl', port: 8999, room: '', password: null, } } */ @InjectService(SyncplayProtocol) @Configurable({instances: []}) class SyncplayServicePlugin extends Plugin { initialize(): void { const protoList = this.validateConfiguration(); this.startAll(protoList); } private startAll(list: any[]): void { for (const ins of list) { const newProto = new SyncplayProtocol(this, ins); logger.log('[%s] Starting Syncplay service "%s".', this.name, ins.name); this.monitor(newProto); this.service?.use(newProto, true); } } private monitor(proto: Protocol): void { proto.on('running', () => this.emit('protocolNew', proto)); proto.on('stopped', () => this.emit('protocolExit', proto)); } private validateConfiguration(): any[] { if (!this.config.config.instances) { throw new Error('Configuration incomplete!'); } const instances = this.config.config.instances; const runnables: any[] = []; for (const ins of instances) { if (ins.enabled === false) { continue; } if (!ins.name || !ins.syncplay || !ins.syncplay.name || !ins.syncplay.host || !ins.syncplay.room) { throw new Error('Invalid instance configuration!'); } runnables.push(ins); } return runnables; } @EventListener('pluginUnload') public unloadEventHandler(plugin: string | Plugin): void { if (plugin === this.name || plugin === this) { this.service?.stopAll().then(() => this.emit('pluginUnloaded', this)); } } } module.exports = SyncplayServicePlugin;