commit 64b5af1219c5b81909809b016beae3b088dfc860 Author: Evert Prants Date: Sun Dec 13 22:14:35 2020 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d09377 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/node_modules/ +/.out/ +deployment.json +*.js +*.d.ts +*.tsbuildinfo \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9295207 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,146 @@ +{ + "name": "service-syncplay", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@squeebot/core": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@squeebot/core/-/core-3.1.0.tgz", + "integrity": "sha512-VtXtEFVYEAGckWhPdpPIKRyVblaGWEn/7/RidkMW4zwBVuiwZrCdzSw/QA00nP9E1R1BPgym+J7YjJA450mDug==", + "requires": { + "dateformat": "^4.0.0", + "fs-extra": "^9.0.1", + "semver": "^7.3.2", + "tar": "^6.0.5" + } + }, + "@types/node": { + "version": "14.14.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.13.tgz", + "integrity": "sha512-vbxr0VZ8exFMMAjCW8rJwaya0dMCDyYW2ZRdTyjtrCvJoENMpdUHOT/eTzvgyA5ZnqRZ/sI0NwqAxNHKYokLJQ==", + "dev": true + }, + "at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==" + }, + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" + }, + "dateformat": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.3.1.tgz", + "integrity": "sha512-xhq1wI5BQ0TMJDvio0BLP8lNeYlhAvmh/7H52H9n6kfzqSmRpIhH5KEIjJ7onFEAh5CQVrAP2MAG8wZ6j0BKzQ==" + }, + "fs-extra": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz", + "integrity": "sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ==", + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^1.0.0" + } + }, + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "requires": { + "minipass": "^3.0.0" + } + }, + "graceful-fs": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" + }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + }, + "dependencies": { + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" + } + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "minipass": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz", + "integrity": "sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==", + "requires": { + "yallist": "^4.0.0" + } + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + }, + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "tar": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.0.5.tgz", + "integrity": "sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg==", + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^3.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + } + }, + "typescript": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", + "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==" + }, + "universalify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz", + "integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3f2f671 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "service-syncplay", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "build": "tsc", + "watch": "tsc -w" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@squeebot/core": "^3.1.0", + "typescript": "^4.1.3" + }, + "devDependencies": { + "@types/node": "^14.14.13" + } +} diff --git a/squeebot.repo.json b/squeebot.repo.json new file mode 100644 index 0000000..172c92d --- /dev/null +++ b/squeebot.repo.json @@ -0,0 +1,14 @@ +{ + "name": "service-syncplay", + "plugins": [ + { + "name": "syncplay", + "version": "1.0.0" + }, + { + "name": "syncplaystatus", + "version": "1.0.0" + } + ], + "typescript": true +} diff --git a/syncplay/plugin.json b/syncplay/plugin.json new file mode 100644 index 0000000..9c7196b --- /dev/null +++ b/syncplay/plugin.json @@ -0,0 +1,9 @@ +{ + "main": "plugin.js", + "name": "syncplay", + "description": "Syncplay Service for Squeebot 3", + "version": "1.0.0", + "tags": ["service", "syncplay"], + "dependencies": ["control?"], + "npmDependencies": [] +} diff --git a/syncplay/plugin.ts b/syncplay/plugin.ts new file mode 100644 index 0000000..ca67994 --- /dev/null +++ b/syncplay/plugin.ts @@ -0,0 +1,362 @@ +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; diff --git a/syncplaystatus/plugin.json b/syncplaystatus/plugin.json new file mode 100644 index 0000000..0b7b91f --- /dev/null +++ b/syncplaystatus/plugin.json @@ -0,0 +1,9 @@ +{ + "main": "plugin.js", + "name": "syncplaystatus", + "description": "Display status of a Syncplay protocol connection", + "version": "1.0.0", + "tags": ["syncplay", "commands", "status"], + "dependencies": ["syncplay", "simplecommands"], + "npmDependencies": [] +} diff --git a/syncplaystatus/plugin.ts b/syncplaystatus/plugin.ts new file mode 100644 index 0000000..df1a1ba --- /dev/null +++ b/syncplaystatus/plugin.ts @@ -0,0 +1,119 @@ +import { + Plugin, + Configurable, + EventListener, + DependencyLoad, + DependencyUnload +} from '@squeebot/core/lib/plugin'; + +import { IMessage, MessageResolver } from '@squeebot/core/lib/types'; +import { fullIDMatcher } from '@squeebot/core/lib/common'; + +interface Monitor { + rooms: string[]; + protocol: string; +} + +/* + Monitor: + { + rooms: ['irc/icynet/#diamond'], + protocol: 'syncplay' + } +*/ + +@Configurable({ + monitors: [] +}) +class SPSPlugin extends Plugin { + public syncPlugin: Plugin | null = null; + + @EventListener('pluginUnload') + public unloadEventHandler(plugin: string | Plugin): void { + if (plugin === this.name || plugin === this) { + this.config.save().then(() => + this.emit('pluginUnloaded', this)); + } + } + + @DependencyLoad('simplecommands') + addCommands(cmd: any): void { + const rooms: string[] = []; + this.config.get('monitors', []).forEach( + (monitor: Monitor) => rooms.push(...monitor.rooms)); + + if (!rooms.length) { + return; + } + + cmd.registerCommand({ + name: 'syncplay', + plugin: this.name, + execute: async (msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[]): Promise => { + if (!msg.fullRoomID || !this.syncPlugin) { + return true; + } + let monitor: Monitor | undefined; + this.config.get('monitors', []).forEach((mon: Monitor) => { + let matched = false; + for (const r of mon.rooms) { + if (fullIDMatcher(msg.fullRoomID as string, r)) { + matched = true; + break; + } + } + if (matched) { + monitor = mon; + } + }); + if (!monitor) { + return true; + } + + const syncServ = this.syncPlugin.service?.getProtocolByName(monitor.protocol) as any; + if (!syncServ) { + return true; + } + + const keys = []; + + keys.push(['field', 'Syncplay', { color: 'orange', type: 'title' }]); + + const realViews = Object.keys(syncServ.users).length - 1; + const cfg = syncServ.config.syncplay; + + if (simplified[0] && simplified[0].toLowerCase() === 'users') { + const w = Object.keys(syncServ.users).filter((x) => x !== cfg.name); + + keys.push(['field', realViews > 0 ? + w.join(', ') : 'No users', { label: 'Users online' }]); + + msg.resolve(keys); + return true; + } + + keys.push(['field', cfg.room, { label: 'Room', type: 'description' }]); + keys.push(['field', `${cfg.host}:${cfg.port}`, { label: 'Address' }]); + keys.push(['field', realViews, { label: 'Users' }]); + + msg.resolve(keys); + return true; + }, + usage: '[users]', + description: 'Show Syncplay status', + source: rooms, + }); + } + + @DependencyLoad('syncplay') + syncplayReady(plugin: any): void { + this.syncPlugin = plugin; + } + + @DependencyUnload('syncplay') + syncplayUnready(): void { + this.syncPlugin = null; + } +} + +module.exports = SPSPlugin; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f460d0c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"downlevelIteration":true,"esModuleInterop":true,"experimentalDecorators":true,"forceConsistentCasingInFileNames":true,"skipLibCheck":true,"sourceMap":false,"strict":true,"target":"es5"}} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..87fa89d --- /dev/null +++ b/tslint.json @@ -0,0 +1,122 @@ +{ + "extends": "tslint:recommended", + "rules": { + "align": { + "options": [ + "parameters", + "statements" + ] + }, + "array-type": false, + "arrow-return-shorthand": true, + "curly": true, + "deprecation": { + "severity": "warning" + }, + "eofline": true, + "import-spacing": true, + "indent": { + "options": [ + "spaces" + ] + }, + "max-classes-per-file": false, + "max-line-length": [ + true, + 140 + ], + "member-ordering": [ + true, + { + "order": [ + "static-field", + "instance-field", + "static-method", + "instance-method" + ] + } + ], + "no-console": [ + true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-empty": false, + "no-inferrable-types": [ + true, + "ignore-params" + ], + "no-non-null-assertion": false, + "no-redundant-jsdoc": true, + "no-switch-case-fall-through": true, + "no-var-requires": false, + "object-literal-key-quotes": [ + true, + "as-needed" + ], + "quotemark": [ + true, + "single" + ], + "semicolon": { + "options": [ + "always" + ] + }, + "space-before-function-paren": { + "options": { + "anonymous": "never", + "asyncArrow": "always", + "constructor": "never", + "method": "never", + "named": "never" + } + }, + "typedef": [ + true, + "call-signature" + ], + "forin": false, + "ban-types": { + "function": false + }, + "typedef-whitespace": { + "options": [ + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + }, + { + "call-signature": "onespace", + "index-signature": "onespace", + "parameter": "onespace", + "property-declaration": "onespace", + "variable-declaration": "onespace" + } + ] + }, + "variable-name": { + "options": [ + "ban-keywords", + "check-format", + "allow-pascal-case" + ] + }, + "whitespace": { + "options": [ + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type", + "check-typecast" + ] + } + } +}