service-syncplay/syncplay/plugin.ts
2020-12-13 22:14:35 +02:00

363 lines
8.2 KiB
TypeScript

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');
}
this.socket = net.connect(opts, () => {
this.write({
Hello: {
username: this.config.syncplay.name,
password,
room: { name: this.config.syncplay.room },
version: PROTOCOL_VERSION
}
});
});
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 {
let response = util.format(data[0], ...data.slice(1));
if (!response || !this.socket) {
return;
}
if (Array.isArray(data[0])) {
try {
response = this.format.compose(data[0]);
} catch (e) {
logger.error('[%s] Failed to compose message:', this.fullName, e.message);
return;
}
}
// Send lines and max length exceeding messages separately
if (!this.info || !this.info.features) {
return;
}
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 }));
}
}
/*
{
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.config.save().then(() =>
this.service?.stopAll().then(() =>
this.emit('pluginUnloaded', this)));
}
}
}
module.exports = SyncplayServicePlugin;