378 lines
8.6 KiB
TypeScript
378 lines
8.6 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');
|
|
}
|
|
|
|
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<boolean> {
|
|
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;
|