From a0bded664ab5e66660eec72f5be1c2bd3691929f Mon Sep 17 00:00:00 2001 From: icedream Date: Thu, 15 Oct 2015 17:25:10 +0200 Subject: [PATCH] Initial commit. - TS3Bot is able to play any link that VLC supports via the "play" command, just add any URI as an argument to that command. - TS3Bot is able to stop the currently playing media via the "stop" command. - TS3Bot is able to change its own nickname via the "changenick" command. --- .gitignore | 1 + app.iced | 194 ++++++++++ app.js | 61 +++ config.iced | 38 ++ logger.iced | 50 +++ package.json | 43 +++ require_bin.iced | 18 + service_depcomparer.iced | 6 + service_template.iced | 154 ++++++++ services.iced | 70 ++++ services/api.iced | 95 +++++ services/blackbox.iced | 76 ++++ services/pulseaudio.iced | 103 ++++++ services/ts3client.iced | 264 +++++++++++++ services/vlc.iced | 87 +++++ services/xvfb.iced | 46 +++ sugar_property.iced | 13 + ts3query.iced | 781 +++++++++++++++++++++++++++++++++++++++ ts3settings.iced | 223 +++++++++++ x11.iced | 76 ++++ 20 files changed, 2399 insertions(+) create mode 100644 .gitignore create mode 100644 app.iced create mode 100644 app.js create mode 100644 config.iced create mode 100644 logger.iced create mode 100644 package.json create mode 100644 require_bin.iced create mode 100644 service_depcomparer.iced create mode 100644 service_template.iced create mode 100644 services.iced create mode 100644 services/api.iced create mode 100644 services/blackbox.iced create mode 100644 services/pulseaudio.iced create mode 100644 services/ts3client.iced create mode 100644 services/vlc.iced create mode 100644 services/xvfb.iced create mode 100644 sugar_property.iced create mode 100644 ts3query.iced create mode 100644 ts3settings.iced create mode 100644 x11.iced diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10c5b9a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +iced diff --git a/app.iced b/app.iced new file mode 100644 index 0000000..ec9f0a5 --- /dev/null +++ b/app.iced @@ -0,0 +1,194 @@ +Sync = require "sync" + +config = require("./config") +getLogger = require("./logger") +services = require("./services") +sync = require "sync" +request = require "request" +fs = require("fs") +path = require("path") +qs = require "querystring" + +log = getLogger "Main" + +# http://stackoverflow.com/a/7117336 +removeBB = (str) -> str.replace /\[(\w+)[^\]]*](.*?)\[\/\1]/g, "$2" + +module.exports = + shutdown: (cb) => + apiService = services.find("api") + if apiService and apiService.state == "started" + await apiService.stop defer(err) + if err + cb? new Error "Could not stop API" + return + + ts3clientService = services.find("ts3client") + if ts3clientService and ts3clientService.state == "started" + await ts3clientService.stop defer(err) + if err + cb? new Error "Could not stop TeamSpeak3" + return + + log.debug "Shutting down services..." + await services.shutdown defer(err) + if err + cb? new Error "Error while shutting down rest of services." + log.debug "Services shut down." + + cb?() + shutdownSync: => Sync @shutdown + +# PulseAudio daemon +await services.find("pulseaudio").start defer err +if err + log.warn "PulseAudio could not start up, audio may not act as expected!" + +# TeamSpeak3 +ts3clientService = services.find("ts3client") + +ts3clientService.on "started", (ts3proc) => + ts3query = ts3clientService.query + + ts3query.currentScHandlerID = 1 + ts3query.mydata = {} + + ts3query.on "open", => + log.info "TS3 query now ready." + + attempts = 0 + err = null + init = true + while init or err != null + init = false + if err + attempts++ + if attempts == 10 + log.error "Could not register to TeamSpeak3 client events, giving up!" + break + else + log.warn "Could not register to TeamSpeak3 client events!", err + for eventName in [ + "notifytalkstatuschange" + "notifyconnectstatuschange" + "notifytextmessage" + "notifyclientupdated" + "notifycliententerview" + "notifyclientleftview" + "notifyclientchatclosed" + "notifyclientchatcomposing" + "notifyclientchannelgroupchanged" + "notifyclientmoved" + ] + await ts3query.clientnotifyregister ts3query.currentScHandlerID, eventName, defer(err) + if err + break + + ts3query.on "message.selected", (args) => + if args["schandlerid"] + ts3query.currentScHandlerID = parseInt args["schandlerid"] + + ts3query.on "message.notifytalkstatuschange", (args) => + await ts3query.use args.schandlerid, defer(err, data) + + ts3query.on "message.notifyconnectstatuschange", (args) => + await ts3query.use args.schandlerid, defer(err, data) + + if args.status == "disconnected" and ts3clientService.state != "stopping" + log.warn "Disconnected from TeamSpeak server, reconnecting in a few seconds..." + ts3clientService.stopSync() + setTimeout (() => ts3clientService.restartSync()), 8000 + + if args.status == "connecting" + log.info "Connecting to TeamSpeak server..." + + if args.status == "connection_established" + log.info "Connected to TeamSpeak server." + + ts3query.on "message.notifyclientupdated", (args) => + await ts3query.use args.schandlerid, defer(err, data) + await ts3query.whoami defer(err, data) + if not err + ts3query.mydata = data + + ts3query.on "message.notifytextmessage", (args) => + await ts3query.use args.schandlerid, defer(err, data) + + msg = args.msg + invoker = { name: args.invokername, uid: args.invokeruid, id: args.invokerid } + targetmode = args.targetmode # 1 = private, 2 = channel + + log.info "<#{invoker.name}> #{msg}" + + # cheap argument parsing here + firstSpacePos = msg.indexOf " " + if firstSpacePos == 0 + return + if firstSpacePos > 0 + name = msg.substring 0, firstSpacePos + paramline = msg.substring firstSpacePos + 1 + params = paramline.match(/'[^']*'|"[^"]*"|[^ ]+/g) || []; + else + name = msg + paramline = "" + params = [] + + switch name.toLowerCase() + when "play" + q = + uid: invoker.uid + input: removeBB paramline + await request "http://127.0.0.1:16444/play?#{qs.stringify q}", defer(err, response) + switch response.statusCode + when 200 then ts3query.sendtextmessage args.targetmode, invoker.id, "Now playing #{paramline}." + when 400 then ts3query.sendtextmessage args.targetmode, invoker.id, "Something seems to be wrong with what you wrote. Maybe check the URL/sound name you provided?" + when 403 then ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, you're not allowed to play #{q.input} via the bot." + else + log.warn "API reported error", response.statusCode, err + ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, an error occurred. Try again later." + when "stop" + q = + uid: invoker.uid + await request "http://127.0.0.1:16444/stop?#{qs.stringify q}", defer(err, response) + switch response.statusCode + when 200 then ts3query.sendtextmessage args.targetmode, invoker.id, "Stopped playback." + when 403 then ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, you're not allowed to do that." + else + log.warn "API reported error", response.statusCode, err + ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, an error occurred. Try again later." + when "setvolume" + q = + uid: invoker.uid + volume: parseFloat paramline + await request "http://127.0.0.1:16444/setvolume?#{qs.stringify q}", defer(err, response) + switch response.statusCode + when 200 then ts3query.sendtextmessage args.targetmode, invoker.id, "Set volume to #{q.volume}" + when 400 then ts3query.sendtextmessage args.targetmode, invoker.id, "Something seems to be wrong with what you wrote. Maybe check the volume? It's supposed to be a floating-point number between 0 and 2." + when 403 then ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, you're not allowed to do that." + else + log.warn "API reported error", response.statusCode, err + ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, an error occurred. Try again later." + when "changenick" + nick = if paramline.length > params[0].length then paramline else params[0] + if nick.length < 1 or nick.length > 32 + ts3query.sendtextmessage args.targetmode, invoker.id, "Invalid nickname." + return + Sync () => + try + ts3query.clientupdate.sync ts3query, { client_nickname: nick } + catch err + ts3query.sendtextmessage args.targetmode, invoker.id, "That unfortunately didn't work out." + log.warn "ChangeNick failed, error information:", err + +await ts3clientService.start [ config.get("ts3-server") ], defer(err, ts3proc) +if err + log.error "TeamSpeak3 could not start, shutting down." + await module.exports.shutdown defer() + process.exit 1 + +# HTTP API +await services.find("api").start defer err +if err + log.error "API could not start up, shutting down!" + await module.exports.shutdown defer() + process.exit 1 \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..0d5b6c1 --- /dev/null +++ b/app.js @@ -0,0 +1,61 @@ +require("iced-coffee-script/register"); +Sync = require("sync"); + +var services = require("./services"); + +var getLogger = require("./logger"); +var log = getLogger("app"); + +// compatibility with Windows for interrupt signal +if (process.platform === "win32") { + var rl = require("readline").createInterface({ + input: process.stdin, + output: process.stdout + }); + rl.on("SIGINT", function() { + return process.emit("SIGINT"); + }); +} + +app = require("./app.iced"); + +doShutdownAsync = function(cb) { + log.info("App shutdown starting..."); + app.shutdown(function() { + log.info("Services shutdown starting..."); + services.shutdown(function() { + if (cb && typeof cb === "function") + cb(); + }); + }); +}; + +process.on("exit", function(e) { + log.debug("Triggered exit", e); + app.shutdownSync(); +}); + +process.on("SIGTERM", function(e) { + log.debug("Caught SIGTERM signal"); + app.shutdown(); +}); + +process.on("SIGINT", function() { + log.debug("Caught SIGINT signal"); + app.shutdown(); +}); + +process.on("SIGHUP", function() { + log.debug("Caught SIGHUP signal"); + app.shutdown(); +}); + +process.on("SIGQUIT", function() { + log.debug("Caught SIGQUIT signal"); + app.shutdown(); +}); + +process.on("SIGABRT", function() { + log.debug("Caught SIGABRT signal"); + app.shutdown(); +}); diff --git a/config.iced b/config.iced new file mode 100644 index 0000000..d4bf2de --- /dev/null +++ b/config.iced @@ -0,0 +1,38 @@ +nconf = require "nconf" +path = require "path" +merge = require "merge" +pwgen = require "password-generator" + +console.log "Loading configuration..." + +# Build configuration object from input +nconf.env [ "NODE_ENV", "PULSE_BINARY" ] +nconf.argv() +nconf.file path.join(process.env["HOME"], ".ts3bot", "config.json") +nconf.defaults + # read http://stackoverflow.com/q/12252043 on why I'm using .trim here + "environment": process.env.NODE_ENV?.trim() or "development" + "log-path": "." + "vlc-host": "0.0.0.0" + "vlc-port": 8080 + "vlc-password": pwgen() + "nickname": "TS3Bot" + "ts3-install-path": path.resolve __dirname, "..", "ts3client" + "ts3-config-path": path.join process.env.HOME, ".ts3client" + "xvfb-resolution": "800x600x16" + "console-log-level": "info" + "file-log-level": "debug" + "PULSE_BINARY": "pulseaudio" + +# Validate configuration +if not nconf.get("ts3-server") + throw new Error "You need to provide a TeamSpeak3 server URL (starts with ts3server:// and can be generated from any TS3 client GUI)." + +console.log "Configuration loaded." + +if nconf.get "dump-config" + console.log nconf.get() + process.exit 0 + +module.exports = merge true, nconf, + isProduction: -> @get("environment").toUpperCase() == "PRODUCTION" \ No newline at end of file diff --git a/logger.iced b/logger.iced new file mode 100644 index 0000000..6c5003e --- /dev/null +++ b/logger.iced @@ -0,0 +1,50 @@ +winston = require "winston" +path = require "path" +config = require "./config" +merge = require "merge" +winstonCommon = require "winston/lib/winston/common" + +winston.emitErrs = true + +transports = [] + +# console logging +console.log "Minimal logging level for console is #{config.get("console-log-level")}" +transports.push new (winston.transports.Console) + colorize: not config.get("json") + silent: config.get("quiet") or config.get("silent") or false + json: config.get("json") or false + stringify: config.get("json") and config.get("json-stringify") or false + timestamp: config.get("timestamp") or false + debugStdout: not config.get("debug-stderr") + prettyPrint: not config.get("json") + level: config.get("console-log-level") + +# file logging +if not config.get("disable-file-logging") + transports.push new (winston.transports.File) + filename: path.join config.get("log-path"), "#{config.get("environment")}.log" + tailable: true + zippedArchive: config.get("zip-logs") or false + level: config.get("file-log-level") + if config.get("json") + transports.push new (winston.transports.File) + filename: path.join config.get("log-path"), "#{config.get("environment")}.json" + json: true + tailable: true + zippedArchive: config.get("zip-logs") or false + level: config.get("file-log-level") + +container = new (winston.Container) + transports: transports + +initialized_loggers = [] + +module.exports = (name, options) => + if not(name in initialized_loggers) + logger = container.add name + logger.addFilter (msg, meta, level) => "[#{name}] #{msg}" + initialized_loggers.push name + return logger + + container.get name \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..2e2c7e1 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "ts3bot", + "version": "0.2.0", + "description": "Allows running TeamSpeak3 as a bot for all kinds of media (local music/videos/streams/YouTube/...) without the need for a real GUI to exist.", + "main": "app.js", + "keywords": [ + "teamspeak", + "teamspeak3", + "ts3", + "bot", + "ts3bot", + "teamspeak3bot", + "music", + "playback", + "audio", + "video", + "media", + "musicbot" + ], + "author": "Carl Kittelberger ", + "license": "GPL-3.0+", + "dependencies": { + "express": "^4.13.3", + "iced-coffee-script": "^108.0.8", + "merge": "^1.2.0", + "mkdirp": "^0.5.1", + "nconf": "^0.7.2", + "npm-which": "^2.0.0", + "password-generator": "^2.0.1", + "querystring": "^0.2.0", + "request": "^2.61.0", + "simple-ini": "^1.0.3", + "sqlite3": "^3.1.0", + "stream-splitter": "^0.3.2", + "string.prototype.startswith": "^0.2.0", + "sync": "^0.2.5", + "valid-url": "^1.0.9", + "vlc-api": "0.0.0", + "which": "^1.1.2", + "winston": "^1.0.1", + "xvfb": "git://github.com/icedream/node-xvfb.git" + } +} diff --git a/require_bin.iced b/require_bin.iced new file mode 100644 index 0000000..66158f2 --- /dev/null +++ b/require_bin.iced @@ -0,0 +1,18 @@ +which = require("which").sync +path = require "path" +log = require("./logger")("RequireBin") + +module.exports = (binName) => + # check if xvfb is findable from here + if path.resolve(binName) == path.normalize(binName) + # this is an absolute path + return binName + + log.silly "Detecting #{binName}..." + try + binPath = which binName + log.debug "#{binName} detected:", binPath + return binPath + catch err + log.error "#{binName} could not be found.", err + throw new Error "#{binName} could not be found." diff --git a/service_depcomparer.iced b/service_depcomparer.iced new file mode 100644 index 0000000..18621a3 --- /dev/null +++ b/service_depcomparer.iced @@ -0,0 +1,6 @@ +module.exports = (a, b) -> + if a.dependencies.indexOf(b.name) >= 0 + return -1; # a before b + if b.dependencies.indexOf(a.name) >= 0 + return 1; # a after b + return 0; # does not matter \ No newline at end of file diff --git a/service_template.iced b/service_template.iced new file mode 100644 index 0000000..5f863d6 --- /dev/null +++ b/service_template.iced @@ -0,0 +1,154 @@ +Sync = require "sync" + +getLogger = require "./logger" +EventEmitter = require("events").EventEmitter +merge = require "merge" +services = require "./services" + +module.exports = class Service extends EventEmitter + constructor: (@name, funcs) -> + @log = getLogger @name + @_funcs = funcs + @_funcs.log = @log # for bind lovers and coffeescript fat arrow (=>) lovers + + @on "started", => @emit "_ready" + @on "stopped", => @emit "_ready" + + if not @dependencies + @dependencies = [] + + state: "stopped" + + start: () => @_start.apply @, [ false ].concat Array.prototype.slice.call(arguments) + + startSync: () => Sync () => @start.sync @ + + _start: (quiet, args...) => + if typeof quiet != "boolean" + throw new "quiet parameter must be a boolean" + + serviceArgs = args.slice 0 + cb = serviceArgs.pop() + if typeof cb != "function" + throw new Error "Callback needs to be given and needs to be a function" + + # wait until state is definite + if @state != "started" and @state != "stopped" + await @on "_ready", defer() + + if @state != "stopped" + @log.warn "Requested startup of #{@name} but it needs to be stopped, current state is #{@state}." + if @state == "started" + @_funcs.start.apply @, serviceArgs.concat [ cb ] # start should return service object and null-error to callback + else + cb? new Error "Invalid state" + return + + # make sure dependencies are running + dependencyServices = [] + for serviceName in @dependencies + service = services.find serviceName + if not service + @log.error "Could not find dependency #{serviceName}!" + cb? new Error "Dependency #{serviceName} not found" + return + dependencyServices.push service + dependencyServices.sort require("./service_depcomparer") # sort services by dependency + for service in dependencyServices + if service.state != "started" + await service.start defer err + if err + @log.error "Dependency #{service.name} failed, can't start #{@name}" + cb? new Error "Dependency #{service.name} failed" + return + + if not quiet + @log.info "Starting #{@name}" + + @state = "starting" + @emit "starting" + + await @_funcs.start.apply @, serviceArgs.concat [ defer(err, service) ] + if err + cb? err + @emit "startfail", err + @state = "stopped" + return + + if not quiet + @log.info "Started #{@name}" + + @_lastArgs = args + + @state = "started" + @emit "started", service + + cb? null, service + + stop: () => @_stop.apply @, [ false ].concat Array.prototype.slice.call(arguments) + + stopSync: () => Sync () => @stop.sync @ + + _stop: (quiet, args...) => + if typeof quiet != "boolean" + throw new "quiet parameter must be a boolean" + + serviceArgs = args.slice 0 + cb = serviceArgs.pop() + if typeof cb != "function" + throw new Error "Callback needs to be given and needs to be a function" + + # wait until state is definite + if @state != "started" and @state != "stopped" + await @on "_ready", defer() + + if @state != "started" + @log.warn "Requested shutdown of #{@name} but it needs to be started, current state is #{@state}." + cb? new Error "Invalid state" + return + + if not quiet + @log.info "Stopping #{@name}" + + @state = "stopping" + @emit "stopping" + + await @_funcs.stop.apply @, serviceArgs.concat [ defer(err, service) ] + if err + cb? err + @state = "started" + @emit "stopfail", err + return + + if not quiet + @log.info "Stopped #{@name}" + + @state = "stopped" + @emit "stopped" + + cb?() + + restart: (cb) => + # wait until state is definite + if @state != "started" and @state != "stopped" + await @on "_ready", defer() + + @log.info "Restarting #{@name}" + @emit "restarting" + + if @state == "started" + await @_stop true, defer(err) + if err + cb? err + + if @state == "stopped" + await @_start true, @_lastArgs..., defer(err) + if err + cb? err + + @log.info "Restarted #{@name}" + @emit "restarted" + + cb? err + + restartSync: () => Sync () => @restart.sync @ \ No newline at end of file diff --git a/services.iced b/services.iced new file mode 100644 index 0000000..917021b --- /dev/null +++ b/services.iced @@ -0,0 +1,70 @@ +# At this point I feel like I'm writing my own init system. Phew... + +merge = require "merge" +getLogger = require("./logger") +EventEmitter = require("events").EventEmitter +log = getLogger("ServiceMgr") +Sync = require "sync" + +getLegacyServiceName = (serviceName) -> serviceName.toLowerCase().replace(/[^A-z0-9]/g, "_") + +module.exports = + services: [] + + find: (serviceName) -> + serviceNameUpper = serviceName.toUpperCase() + for service in services + if service.name.toUpperCase() == serviceNameUpper + return service + null + + register: (service) -> + if @[service.name] + throw new Error "There is already a service registered under that name" + @services.push service + log.debug "Registered service #{service.name}" + + unregister: (serviceName) -> + for service, index in @services + if service.name == serviceName + @services.splice index, 1 + log.debug "Unregistered service #{service.name}" + throw new Error "There is no service registered under that name" + + shutdown: (cb) -> + shutdownOrder = @services.splice 0 + shutdownOrder.reverse() + + while true + for own k, v of shutdownOrder + if v.state == "stopped" + continue + await v.stop defer() + breakOut = true + for own k, v of shutdownOrder + if v.state != "stopped" + log.debug "Service #{k} in state #{v.state} after shutdown loop, relooping" + breakOut = false + if breakOut + break + cb?() + + shutdownSync: () -> Sync () => @shutdown.sync @ + +# base class for all services +module.exports.Service = require "./service_template" + +# register services +services = [ + new(require "./services/api") + new(require "./services/pulseaudio") + new(require "./services/ts3client") + new(require "./services/vlc") + new(require "./services/xvfb") + new(require "./services/blackbox") +] +services.sort require("./service_depcomparer") # sort services by dependency +for service in services + module.exports.register service + +module.exports.services = services \ No newline at end of file diff --git a/services/api.iced b/services/api.iced new file mode 100644 index 0000000..f92fd04 --- /dev/null +++ b/services/api.iced @@ -0,0 +1,95 @@ +express = require "express" +url = require "url" +path = require "path" +spawn = require("child_process").spawn +net = require "net" +Socket = net.Socket +getLogger = require "../logger" +config = require "../config" +log = getLogger "API" +#PulseAudio = require "pulseaudio" +isValidUrl = (require "valid-url").isWebUri + +services = require "../services" + +module.exports = class APIService extends services.Service + dependencies: [ + "pulseaudio" + "vlc" + "ts3client" + ] + constructor: () -> super "API", + start: (cb) -> + if @httpServer + cb? null + return + + vlc = services.find("vlc").instance + ts3query = services.find("ts3client").query + + # set up HTTP server + log.debug "Starting up HTTP API..." + app = express() + app.get "/play", (req, res) => + if not req.query.uid + log.debug "Didn't get a UID, sending forbidden" + res.status(400).send("Forbidden") + return + if not req.query.input + log.debug "Didn't get an input URI/alias, sending bad request" + res.status(400).send("Bad request") + return + + input = null + # only allow playback from file if it's a preconfigured alias + if isValidUrl req.query.input + log.debug "Got input URL:", req.query.input + input = req.query.input + else + input = config.get("aliases:#{req.query.input}") + if not(isValidUrl input) and not(fs.existsSync input) + log.debug "Got neither valid URL nor valid alias:", req.query.input + res.status(403).send("Forbidden") + return + + # TODO: permission system to check if uid is allowed to play this url or alias + + await vlc.status.empty defer(err) + if err + res.status(503).send("Something went wrong") + log.warn "VLC API returned an error when trying to empty", err + return + + await vlc.status.play input, defer(err) + if err + vlc.status.empty() + res.status(503).send("Something went wrong") + log.warn "VLC API returned an error when trying to play", err + return + + res.send("OK") + + app.get "/stop", (req, res) => + if not req.query.uid + log.debug "Didn't get a UID, sending forbidden" + res.status(403).send("Forbidden - missing UID") + return + + # TODO: permission system to check if uid is allowed to stop playback + + vlc.status.stop() + vlc.status.empty() + + res.send("OK") + + app.get "/setvolume", (req, res) => + throw new "Not implemented yet" # FIXME below, still need to implement audio + + @httpServer = app.listen 16444 + + cb? null + + stop: (cb) -> + @httpServer.close() + + cb?() \ No newline at end of file diff --git a/services/blackbox.iced b/services/blackbox.iced new file mode 100644 index 0000000..e73fdb4 --- /dev/null +++ b/services/blackbox.iced @@ -0,0 +1,76 @@ +spawn = require("child_process").spawn +log = require("../logger")("BlackBox") +services = require("../services") +StreamSplitter = require("stream-splitter") +require_bin = require("../require_bin") + +blackboxBinPath = require_bin "blackbox" + +module.exports = class BlackBoxService extends services.Service + dependencies: [ + "xvfb" + ] + constructor: -> super "BlackBox", + start: (cb) -> + if @process + cb? null, @process + return + + calledCallback = false + + proc = null + doStart = null + doStart = () => + await services.find("xvfb").start defer(err) + if err + throw new Error "Dependency xvfb failed." + + proc = spawn blackboxBinPath, [ "-rc", "/dev/null" ], + stdio: ['ignore', 'pipe', 'pipe'] + detached: true + env: + DISPLAY: process.env.DISPLAY + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR + HOME: process.env.HOME + + # logging + stdoutTokenizer = proc.stdout.pipe StreamSplitter "\n" + stdoutTokenizer.encoding = "utf8"; + stdoutTokenizer.on "token", (token) => + token = token.trim() # get rid of \r + @log.debug token + + stderrTokenizer = proc.stderr.pipe StreamSplitter "\n" + stderrTokenizer.encoding = "utf8"; + stderrTokenizer.on "token", (token) => + token = token.trim() # get rid of \r + @log.warn token + + proc.on "exit", () => + if @state == "stopping" + return + if not calledCallback + calledCallback = true + @log.warn "BlackBox terminated unexpectedly during startup." + cb? new Error "BlackBox terminated unexpectedly." + @log.warn "BlackBox terminated unexpectedly, restarting." + doStart() + + @process = proc + + doStart() + + setTimeout (() => + if not calledCallback + calledCallback = true + cb? null, @process), 1500 # TODO: Use some more stable condition + + stop: (cb) -> + if not @process + cb?() + return + + @process.kill() + await @process.once "exit", defer() + + cb?() diff --git a/services/pulseaudio.iced b/services/pulseaudio.iced new file mode 100644 index 0000000..fc10f9b --- /dev/null +++ b/services/pulseaudio.iced @@ -0,0 +1,103 @@ +spawn = require("child_process").spawn +log = require("../logger")("PulseAudio") +services = require("../services") +config = require("../config") +StreamSplitter = require("stream-splitter") +require_bin = require("../require_bin") + +pulseaudioPath = require_bin config.get("PULSE_BINARY") +pacmdPath = require_bin "pacmd" + +module.exports = class PulseAudioService extends services.Service + dependencies: [ + "xvfb" + ] + constructor: -> super "PulseAudio", + start: (cb) -> + if @process + cb? null, @process + return + + # logging + forwardLog = (token) => + token = token.trim() # get rid of \r + level = token.substring(0, 1).toUpperCase() + msg = token.substring token.indexOf("]") + 2 + switch token.substring(0, 1).toUpperCase() + when "D" then log.silly msg + when "I" then log.silly msg + when "W" then log.warn msg + when "E" then log.error msg + else log.silly msg + + # spawn options + opts = + stdio: ['ignore', 'pipe', 'pipe'] + detached: true + env: + DISPLAY: process.env.DISPLAY + HOME: process.env.HOME + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR + + # check if there is already a daemon running + proc = spawn pulseaudioPath, [ "--check" ], opts + stderrTokenizer = proc.stderr.pipe StreamSplitter "\n" + stderrTokenizer.encoding = "utf8"; + stderrTokenizer.on "token", forwardLog + await proc.once "exit", defer(code, signal) + @log.silly "PulseAudio daemon check returned that #{if code == 0 then "a daemon is already running" else "no daemon is running"}" + if code == 0 + @log.warn "PulseAudio already running on this system" + cb? null, null + return + + proc = spawn pulseaudioPath, [ + "--start" + "--fail=true" # quit on startup failure + "--high-priority=true" + "--daemonize=false" + "-v" + ], opts + + calledCallback = false + + # logging + tokenHandler = (token) => + forwardLog token + + if not calledCallback and (token.indexOf("client.conf") >= 0 or token.indexOf("Daemon startup complete.") >= 0) + calledCallback = true + @process = proc + setTimeout (() => cb? null, @process), 1500 # TODO: Use some more stable condition + stdoutTokenizer = proc.stdout.pipe StreamSplitter "\n" + stdoutTokenizer.encoding = "utf8" + stdoutTokenizer.on "token", tokenHandler + stderrTokenizer = proc.stderr.pipe StreamSplitter "\n" + stderrTokenizer.encoding = "utf8" + stderrTokenizer.on "token", tokenHandler + + proc.on "exit", () => + if not calledCallback + calledCallback = true + cb? new Error "PulseAudio daemon terminated unexpectedly." + + stop: (cb) -> + if not @process + cb?() + return + + @process.kill() + await @process.once "exit", defer() + + cb?() + + findIndexForProcessId: (pid, cb) => throw new Error "Not implemented yet" + + findIndexForProcessIdSync: (pid) => Sync () => @findIndexForProcessId @, pid + + setSinkInputMute: (index, value, cb) => throw new Error "Not implemented yet" + + setSinkInputMuteSync: (index, value) => Sync () => @setSinkInputMute @, index, value + + mute: (index) => @setSinkInputMute index, 1 + unmute: (index) => @setSinkInputMute index, 0 diff --git a/services/ts3client.iced b/services/ts3client.iced new file mode 100644 index 0000000..996e6d5 --- /dev/null +++ b/services/ts3client.iced @@ -0,0 +1,264 @@ +xvfb = require("xvfb") +log = require("../logger")("TS3Client") +config = require("../config") +services = require("../services") +x11tools = require("../x11") +TS3Settings = require("../ts3settings") +TS3ClientQuery = require("../ts3query") +path = require "path" +merge = require "merge" +spawn = require("child_process").spawn +StreamSplitter = require("stream-splitter") +require_bin = require("../require_bin") + +ts3client_binpath = require_bin path.join(config.get("ts3-install-path"), "ts3client_linux_" + (if process.arch == "x64" then "amd64" else process.arch)) + +module.exports = class TS3ClientService extends services.Service + dependencies: [ + "xvfb", + "blackbox", + "pulseaudio" + ] + constructor: -> super "TS3Client", + start: (args, cb) => + if typeof args == "function" + cb = args + args = null + + if @process + cb? null, @process + return + + if not args + args = [] + + await @_preconfigure defer() + + # spawn process + proc = null + doStart = null + forwardLog = (token) => + token = token.trim() # get rid of \r + if token.indexOf("|") > 0 + token = token.split("|") + level = token[1].toUpperCase().trim() + source = token[2].trim() + sourceStr = if source then "#{source}: " else "" + message = token[4].trim() + switch level + when "ERROR" then log.error "%s%s", sourceStr, message + when "WARN" then log.warn "%s%s", sourceStr, message + when "INFO" then log.debug "%s%s", sourceStr, message + else log.silly "%s%s", sourceStr, message + else + log.debug token + scheduleRestart = null # see below + onExit = => # autorestart + @_running = false + @process = null + if @_requestedExit + return + log.warn "TeamSpeak3 unexpectedly terminated!" + scheduleRestart() + doStart = () => + env = + HOME: process.env.HOME + DISPLAY: process.env.DISPLAY + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR + KDEDIRS: '' + KDEDIR: '' + QTDIR: config.get("ts3-install-path") + QT_PLUGIN_PATH: config.get("ts3-install-path") + LD_LIBRARY_PATH: config.get("ts3-install-path") + if process.env.LD_LIBRARY_PATH + env.LD_LIBRARY_PATH += ":#{process.env.LD_LIBRARY_PATH}" + + @log.silly "Environment variables:", env + @log.silly "Arguments:", JSON.stringify args + + @_requestedExit = false + proc = spawn ts3client_binpath, args, + detached: true + stdio: ['ignore', 'pipe', 'pipe'] + cwd: config.get("ts3-install-path") + env: env + @_running = true + + # logging + stdoutTokenizer = proc.stdout.pipe StreamSplitter "\n" + stdoutTokenizer.encoding = "utf8"; + stdoutTokenizer.on "token", forwardLog + + stderrTokenizer = proc.stderr.pipe StreamSplitter "\n" + stderrTokenizer.encoding = "utf8"; + stderrTokenizer.on "token", forwardLog + + # connect to client query plugin when it's loaded + stdoutTokenizer.on "token", (token) => + if token.indexOf("Loading plugin: libclientquery_plugin") >= 0 + # client query plugin is now loading + @_queryReconnectTimer = setTimeout @query.connect.bind(@query), 250 + stderrTokenizer.on "token", (token) => + if token.indexOf("Query: bind failed") >= 0 + # without query this ts3 instance is worthless + await @_ts3GracefulShutdown defer() + scheduleRestart() + + # autorestart + proc.on "exit", onExit + + @process = proc + scheduleRestart = () => + log.warn "Restarting in 5 seconds..." + @_startTimer = setTimeout doStart.bind(@), 5000 + if @_queryReconnectTimer + clearTimeout @_queryReconnectTimer + doStart() + + # ts3 query + @query = new TS3ClientQuery "127.0.0.1", 25639 + @_queryReconnectTimer = null + @query.on "error", (err) => + if not @_requestedExit and @process and @process.connected + log.warn "Error in TS3 query connection, reconnecting..." + @_queryReconnectTimer = setTimeout @query.connect.bind(@query), 1000 + else + log.warn "TS3 query connection terminated." + @query.on "close", => log.debug "Connection to TS3 client query interface lost." + @query.on "open", => log.debug "Connected to TS3 client query interface." + @query.on "connecting", => log.debug "Connecting to TS3 client query interface..." + + cb? null, @process + + stop: (cb) -> @_ts3GracefulShutdown cb + + _ts3GracefulShutdown: (cb) -> + @_requestedExit = true + + if @_startTimer + clearTimeout @_startTimer + + if @_queryReconnectTimer + clearTimeout @_queryReconnectTimer + + if @_running + log.silly "Using xdotool to gracefully shut down TS3" + await x11tools.getWindowIdByProcessId @process.pid, defer(err, wid) + if not wid + log.debug "Can not find a window for #{@name}." + log.warn "Can not properly shut down #{@name}, it will time out on the server instead." + @process.kill() + else + log.silly "Sending keys to TS3" + await x11tools.sendKeys wid, "ctrl+q", defer(err) + if err + log.warn "Can not properly shut down #{@name}, it will time out on the server instead." + log.silly "Using SIGTERM for shutdown of #{@name}" + @process.kill() + + # wait for 10 seconds then SIGKILL if still up + log.silly "Now waiting 10 seconds for shutdown..." + killTimer = setTimeout (() => + log.silly "10 seconds gone, using SIGKILL now since we're impatient." + @process.kill("SIGKILL")), 10000 + await @process.once "exit", defer() + clearTimeout killTimer + + @_running = false + @process = null + else + log.warn "TeamSpeak3 seems to have died prematurely." + + cb?() + + _preconfigure: (cb) => + ts3settings = new TS3Settings config.get("ts3-config-path") + await ts3settings.open defer() + + # Delete bookmars to prevent auto-connect bookmarks from weirding out the client + await ts3settings.query "delete * from Bookmarks", defer() + + # Let's make sure we have an identity! + force = ts3settings.getIdentitiesSize() <= 0 or config.get("identity-path") + if force + if not config.get("identity-path") + throw new Error "Need a file to import the bot's identity from." + ts3settings.clearIdentities() + await ts3settings.importIdentity config.get("identity-path"), defer(identity) + identity.select() + + if config.get("nickname") + # Enforce nickname from configuration + identity = ts3settings.getSelectedIdentity() + identity.nickname = config.get "nickname" + identity.update() + + # Some settings to help the TS3Bot to do what it's supposed to do + await ts3settings.setMultiple [ + [ "Application", "HotkeyMode", "2" ] + [ "Chat", "MaxLines", "1" ] + [ "Chat", "LogChannelChats", "0" ] + [ "Chat", "LogClientChats", "0" ] + [ "Chat", "LogServerChats", "0" ] + [ "Chat", "ReloadChannelChats", "0" ] + [ "Chat", "ReloadClientChats", "0" ] + [ "Chat", "IndicateChannelChats", "0" ] + [ "Chat", "IndicatePrivateChats", "0" ] + [ "Chat", "IndicateServerChats", "0" ] + [ "FileTransfer", "SimultaneousDownloads", "2" ] + [ "FileTransfer", "SimultaneousUploads", "2" ] + [ "FileTransfer", "UploadBandwidth", "0" ] + [ "FileTransfer", "DownloadBandwidth", "0" ] + [ "General", "LastShownLicense", "1" ] # ugh... + [ "General", "LastShownLicenseLang", "C" ] + [ "Global", "MainWindowMaximized", "1" ] + [ "Global", "MainWindowMaximizedScreen", "1" ] + [ "Messages", "Disconnect", config.get "quit-message" ] + [ "Misc", "WarnWhenMutedInfoShown", "1" ] + [ "Misc", "LastShownNewsBrowserVersion", "4" ] + [ "News", "NewsClosed", "1" ] + [ "News", "Language", "en" ] + [ "Plugins", "teamspeak_control_plugin", "false" ] + [ "Plugins", "clientquery_plugin", "true" ] + [ "Plugins", "lua_plugin", "false" ] + [ "Plugins", "test_plugin", "false" ] + [ "Plugins", "ts3g15", "false" ] + [ "Profiles", "DefaultPlaybackProfile", "Default" ] + [ "Profiles", "Playback/Default", { + Device: '' + DeviceDisplayName: "Default" + VolumeModifier: -40 + VolumeFactorWaveDb: -17 + PlayMicClicksOnOwn: false + PlayMicClicksOnOthers: false + MonoSoundExpansion: 2 + Mode: "PulseAudio" + PlaybackMonoOverCenterSpeaker: false + } ] + [ "Profiles", "DefaultCaptureProfile", "Default" ] + [ "Profiles", "Capture/Default", { + Device: '' + DeviceDisplayName: "Default" + Mode: "PulseAudio" + } ] + [ "Profiles", "Capture/Default/PreProcessing", { + continous_transmission: "false" + vad: "true" + vad_over_ptt: "false" + delay_ptt_msecs: "250" + voiceactivation_level: "-49" + echo_reduction: false + echo_cancellation: false + denoise: false + delay_ptt: false + agc: if config.get("ts3-agc") then "true" else "false" + echo_reduction_db: 10 + } ] + [ "Notifications", "SoundPack", "nosounds" ] + [ "Statistics", "ParticipateStatistics", "0" ] + [ "Statistics", "ConfirmedParticipation", "1" ] + ], defer() + + await ts3settings.close defer() + + cb?() diff --git a/services/vlc.iced b/services/vlc.iced new file mode 100644 index 0000000..71f18d4 --- /dev/null +++ b/services/vlc.iced @@ -0,0 +1,87 @@ +spawn = require("child_process").spawn +services = require("../services") +config = require("../config") +VLCApi = require("vlc-api") +StreamSplitter = require("stream-splitter") +require_bin = require("../require_bin") + +vlcBinPath = require_bin "vlc" + +module.exports = class VLCService extends services.Service + dependencies: [ + "pulseaudio" + ] + constructor: -> super "VLC", + start: (cb) -> + if @_process + cb? null, @_process + return + + calledCallback = false + + proc = null + doStart = null + doStart = () => + await services.find("pulseaudio").start defer(err) + if err + throw new Error "Dependency pulseaudio failed." + + proc = spawn vlcBinPath, [ + "-I", "http", + "--http-host", config.get("vlc-host"), + "--http-port", config.get("vlc-port"), + "--http-password", config.get("vlc-password") + "--aout=pulse", + "--no-video" + ], + stdio: ['ignore', 'pipe', 'pipe'] + detached: true + + # logging + stdoutTokenizer = proc.stdout.pipe StreamSplitter "\n" + stdoutTokenizer.encoding = "utf8"; + stdoutTokenizer.on "token", (token) => + token = token.trim() # get rid of \r + @log.debug token + + stderrTokenizer = proc.stderr.pipe StreamSplitter "\n" + stderrTokenizer.encoding = "utf8"; + stderrTokenizer.on "token", (token) => + token = token.trim() # get rid of \r + @log.debug token + + proc.on "exit", () => + if @state == "stopping" + return + if not calledCallback + calledCallback = true + @log.warn "VLC terminated unexpectedly during startup." + cb? new Error "VLC terminated unexpectedly." + @log.warn "VLC terminated unexpectedly, restarting." + doStart() + + @_process = proc + + doStart() + + setTimeout (() => + if not calledCallback + calledCallback = true + + @instance = new VLCApi + host: ":#{encodeURIComponent config.get("vlc-password")}@#{config.get("vlc-host")}", + port: config.get("vlc-port") + cb? null, @instance), 1500 # TODO: Use some more stable condition + + stop: (cb) -> + if not @_process + cb?() + return + + @instance = null + + @_process.kill() + await @_process.once "exit", defer() + + cb?() + diff --git a/services/xvfb.iced b/services/xvfb.iced new file mode 100644 index 0000000..b3f1027 --- /dev/null +++ b/services/xvfb.iced @@ -0,0 +1,46 @@ +Xvfb = require("xvfb") +log = require("../logger")("Xvfb") +config = require("../config") +services = require("../services") +require_bin = require("../require_bin") + +require_bin "Xvfb" + +module.exports = class XvfbService extends services.Service + constructor: -> super "Xvfb", + start: (cb) -> + if @instance + cb? null, @instance + return + + instance = new Xvfb + detached: true + reuse: true + silent: false + timeout: 5000 + xvfb_args: [ + "-screen" + "0" + config.get("xvfb-resolution") + "-ac" + ] + await instance.start defer(err) + + if err + cb? err, null + + @instance = instance + + cb? null, @instance + + stop: (cb) -> + if not @instance + cb?() + return + + await @instance.stop defer(err) + if err + cb? err, null + + @instance = null + cb?() diff --git a/sugar_property.iced b/sugar_property.iced new file mode 100644 index 0000000..d4790f3 --- /dev/null +++ b/sugar_property.iced @@ -0,0 +1,13 @@ +# @property "prop", { desc... } +Function::property = (prop, desc) -> Object.defineProperty @prototype, prop, desc + +# defineProperty "prop", { desc... } +#Object::defineProperty = (prop, desc) -> Object.defineProperty @, prop, desc + +# propertiesof obj +#global.propertiesof = (obj) -> Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertyNames(obj.constructor.prototype or {})) + +# descriptorof obj, name +#global.descriptorof = (obj, name) -> Object.getOwnPropertyDescriptor obj, name + +module.exports = exports = {} \ No newline at end of file diff --git a/ts3query.iced b/ts3query.iced new file mode 100644 index 0000000..dafbfb4 --- /dev/null +++ b/ts3query.iced @@ -0,0 +1,781 @@ +require "string.prototype.startswith" + +net = require "net" +getLogger = require "./logger" +StringDecoder = require("string_decoder").StringDecoder +StreamSplitter = require "stream-splitter" +events = require "events" +EventEmitter = events.EventEmitter +merge = require "merge" + +parserLog = getLogger "parser" + +escape = (value) => value.toString().replace(///\\///g, "\\\\").replace(/\//g, "\\/").replace(/\|/g, "\\|").replace(///\ ///g, "\\s") + +unescape = (value) => value.toString().replace(/\\s/g, " ").replace(/\\\//g, "/").replace(/\\\|/g, "|").replace(/\\\\/g, "\\") + +buildCmd = (name, namedArgs, posArgs) => + # TODO: Add support for collected arguments (aka lists) + if not name + throw new Error "Need command name" + if name.indexOf(" ") >= 0 + throw new Error "Invalid command name" + if name.length > 0 + param = "#{name}" + if namedArgs + for k, v of namedArgs + if v == null + continue + if param.length > 0 + param += " " + param += "#{escape(k)}=#{escape(v)}" + if posArgs + for v in posArgs + if v == null + continue + if param.length > 0 + param += " " + param += "#{escape(v)}" + param + "\n\r" + +parseCmd = (str) => + params = str.split " " + + startIndex = 0 + if params[0].indexOf("=") < 0 + name = params[0] + str = str.substring name.length + 1 + + str = str.split "|" # TODO: Ignore escaped pipes + + collectedArgs = [] + + for s in str + params = s.split " " + args = {} + posArgs = [] + + for i in [0 .. params.length - 1] + value = params[i] + equalsPos = value.indexOf "=" + if equalsPos < 0 + posArgs.push value + continue + key = unescape value.substring(0, equalsPos) + value = value.substring equalsPos + 1 + value = unescape value + args[key] = value + + args._ = posArgs + + collectedArgs.push args + + if collectedArgs.length == 1 + collectedArgs = collectedArgs[0] + + { + name: name + args: collectedArgs + } + +checkError = (err) => + err.id = parseInt err.id + if err.id == 0 + return null + err + +module.exports = class TS3ClientQuery extends EventEmitter + constructor: (host, port) -> + @_log = getLogger "TS3ClientQuery" + @_id = null + @_host = host + @_port = port + + connect: (cb) => + @_tcpClient = new net.Socket + + @_tcpClient.on "close", () => @emit "close" + @_tcpClient.on "error", (err) => + @_log.warn "Connection error", err + @_tcpClient = null + @emit "error", err + + @emit "connecting" + @_tcpClient.connect @_port, @_host, () => + @emit "open" + await @once "message.selected", defer(selectedArgs) + cb?() + + splitterStream = StreamSplitter("\n\r") + splitterStream.encoding = "utf8" + + @_tcpTokenizer = @_tcpClient.pipe splitterStream + + @_tcpTokenizer.on "token", (token) => + token = token.trim() + + if token.startsWith "TS3 Client" or token.startsWith "Welcome" + return # this is just helper text for normal users + + response = parseCmd token + + @_log.silly "Recv:", response + + if response.name + @emit "message.#{response.name}", response.args + else + @emit "vars", response.args + + close: (cb) => + if not @_tcpClient + cb?() + return + @_tcpClient.destroy() + cb?() + + send: (cmd, namedArgs, positionalArgs, cb) => + if not cmd + throw new Error "Need command name" + + if Array.isArray namedArgs + cb = positionalArgs + positionalArgs = namedArgs + namedArgs = {} + + if typeof positionalArgs == "function" + cb = positionalArgs + positionalArgs = [] + + text = buildCmd(cmd, namedArgs, positionalArgs) + + @_log.silly "Send:", { cmd: cmd, namedArgs: namedArgs, positionalArgs: positionalArgs } + + @_tcpClient.write text, "utf8", () => cb?() + + banadd: (cb) => + throw new Error "Not implemented yet" + + banclient: (cb) => + throw new Error "Not implemented yet" + + bandel: (cb) => + throw new Error "Not implemented yet" + + bandelall: (cb) => + throw new Error "Not implemented yet" + + banlist: (cb) => + throw new Error "Not implemented yet" + + channeladdperm: (cb) => + throw new Error "Not implemented yet" + + channelclientaddperm: (cb) => + throw new Error "Not implemented yet" + + channelclientdelperm: (cb) => + throw new Error "Not implemented yet" + + channelclientlist: (cb) => + throw new Error "Not implemented yet" + + channelclientpermlist: (cb) => + throw new Error "Not implemented yet" + + ### + Get channel connection information for specified channelid from the currently + selected server connection handler. If no channelid is provided, information + for the current channel will be received. + ### + channelconnectinfo: (cid, cb) => + if not cb and typeof cid == "function" + cb = cid + cid = null + retval = { } + @once "vars", (args) => merge retval, args + @once "message.error", (args) => cb? checkError(args), retval + @send "channelconnectinfo", + cid: cid + + ### + Creates a new channel using the given properties and displays its ID. + + N.B. The channel_password property needs a hashed password as a value. + The hash is a sha1 hash of the password, encoded in base64. You can + use the "hashpassword" command to get the correct value. + ### + channelcreate: (channel_name, channel_properties, cb) => + if not cb and typeof channel_properties == "function" + cb = channel_properties + channel_properties = {} + if not channel_properties + channel_properties = {} + channel_properties["channel_name"] = channel_name + retval = { } + @once "vars", (args) => merge retval, args + @once "message.error", (args) => cb? checkError(args), retval + @send "channelcreate", channel_properties + + channeldelete: (cb) => + throw new Error "Not implemented yet" + + channeldelperm: (cb) => + throw new Error "Not implemented yet" + + ### + Changes a channels configuration using given properties. + ### + channeledit: (cid, channel_properties, cb) => + @once "message.error", (args) => cb? checkError(args) + @send "channeledit", merge true, channel_properties, + cid: cid + + channelgroupadd: (cb) => + throw new Error "Not implemented yet" + + channelgroupaddperm: (cb) => + throw new Error "Not implemented yet" + + channelgroupclientlist: (cb) => + throw new Error "Not implemented yet" + + channelgroupdel: (cb) => + throw new Error "Not implemented yet" + + channelgroupdelperm: (cb) => + throw new Error "Not implemented yet" + + channelgrouplist: (cb) => + throw new Error "Not implemented yet" + + channelgrouppermlist: (cb) => + throw new Error "Not implemented yet" + + channellist: (cb) => + throw new Error "Not implemented yet" + + channelmove: (cb) => + throw new Error "Not implemented yet" + + channelpermlist: (cb) => + throw new Error "Not implemented yet" + + channelvariable: (cb) => + throw new Error "Not implemented yet" + + clientaddperm: (cb) => + throw new Error "Not implemented yet" + + clientdbdelete: (cb) => + throw new Error "Not implemented yet" + + clientdbedit: (cb) => + throw new Error "Not implemented yet" + + clientdblist: (cb) => + throw new Error "Not implemented yet" + + clientdelperm: (cb) => + throw new Error "Not implemented yet" + + ### + Displays the database ID matching the unique identifier specified by cluid. + ### + clientgetdbidfromuid: (cluid, cb) => + retval = { } + @once "vars", (args) => merge retval, args + @once "message.error", (args) => cb? checkError(args), retval + @send "clientgetdbidfromuid", + cluid: cluid + + ### + Displays all client IDs matching the unique identifier specified by cluid. + ### + clientgetids: (cb) => + retval = { } + @once "vars", (args) => merge retval, args + @once "message.error", (args) => cb? checkError(args), retval + @send "clientgetids", + cluid: cluid + + ### + Displays the unique identifier and nickname matching the database ID specified + by cldbid. + ### + clientgetnamefromdbid: (cldbid, cb) => + retval = { } + @once "vars", (args) => merge retval, args + @once "message.error", (args) => cb? checkError(args), retval + @send "clientgetnamefromdbid", + cldbid: cldbid + + ### + Displays the database ID and nickname matching the unique identifier specified + by cluid. + ### + clientgetnamefromuid: (cluid, cb) => + retval = { } + @once "vars", (args) => merge retval, args + @once "message.error", (args) => cb? checkError(args), retval + @send "clientgetnamefromuid", + cluid: cluid + + ### + Displays the unique identifier and nickname associated with the client + specified by the clid parameter. + ### + clientgetuidfromclid: (clid, cb) => + retval = { } + @once "notifyclientuidfromclid", (args) => merge retval, args + @once "message.error", (args) => cb? checkError(args), retval + @send "clientgetuidfromclid", + clid: clid + + ### + Kicks one or more clients specified with clid from their currently joined + channel or from the server, depending on reasonid. The reasonmsg parameter + specifies a text message sent to the kicked clients. This parameter is optional + and may only have a maximum of 40 characters. + + Available reasonid values are: + 4: Kick the client from his current channel into the default channel + 5: Kick the client from the server + ### + clientkick: (reasonid, reasonmsg, clid, cb) => + if not cb and not clid + cb = clid + clid = reasonmsg + reasonmsg = null + if typeof clid == "function" + cb = clid + clid = null + @once "message.error", (args) => cb? checkError(args), retval + @send "clientkick", + reasonid: reasonid + reasonmsg: reasonmsg + clid: clid + + ### + Displays a list of clients that are known. Included information is the + clientID, nickname, client database id, channelID and client type. + Please take note that the output will only contain clients which are in + channels you are currently subscribed to. Using the optional modifier + parameters you can enable additional information per client. + + Here is a list of the additional display paramters you will receive for + each of the possible modifier parameters. + + -uid: + client_unique_identifier + + -away: + client_away + client_away_message + + -voice: + client_flag_talking + client_input_muted + client_output_muted + client_input_hardware + client_output_hardware + client_talk_power + client_is_talker + client_is_priority_speaker + client_is_recording + client_is_channel_commander + client_is_muted + + -groups: + client_servergroups + client_channel_group_id + + -icon: + client_icon_id + + -country: + client_country + ### + clientlist: (modifiers, cb) => + if not cb + cb = modifiers + modifiers = null + + cleanedModifiers = [] + for v, index in modifiers + if not v.startsWith "-" + v = "-#{v}" + cleanedModifiers.push v + + retval = { } + @once "vars", (args) => merge retval, args + @once "message.error", (args) => cb? checkError(args), retval + @send "clientlist", cleanedModifiers + + clientmove: (cb) => + throw new Error "Not implemented yet" + + clientmute: (cb) => + throw new Error "Not implemented yet" + + ### + This command allows you to listen to events that the client encounters. Events + are things like people starting or stopping to talk, people joining or leaving, + new channels being created and many more. + It registers for client notifications for the specified + serverConnectionHandlerID. If the serverConnectionHandlerID is set to zero it + applies to all server connection handlers. Possible event values are listed + below, additionally the special string "any" can be used to subscribe to all + events. + + Possible values for event: + notifytalkstatuschange + notifymessage + notifymessagelist + notifycomplainlist + notifybanlist + notifyclientmoved + notifyclientleftview + notifycliententerview + notifyclientpoke + notifyclientchatclosed + notifyclientchatcomposing + notifyclientupdated + notifyclientids + notifyclientdbidfromuid + notifyclientnamefromuid + notifyclientnamefromdbid + notifyclientuidfromclid + notifyconnectioninfo + notifychannelcreated + notifychanneledited + notifychanneldeleted + notifychannelmoved + notifyserveredited + notifyserverupdated + channellist + channellistfinished + notifytextmessage + notifycurrentserverconnectionchanged + notifyconnectstatuschange + ### + clientnotifyregister: (schandlerid, event, cb) => + @once "message.error", (args) => cb? checkError(args) + @send "clientnotifyregister", + schandlerid: schandlerid + event: event + + ### + Unregisters from all previously registered client notifications. + ### + clientnotifyunregister: (cb) => + @once "message.error", (args) => cb? checkError(args) + @send "clientnotifyunregister" + + ### + Displays a list of permissions defined for a client. + ### + clientpermlist: (cldbid, cb) => + retval = { } + @once "vars", (args) => merge retval, args + @once "message.error", (args) => cb? checkError(args), retval + @send "clientpermlist", + cldbid: cldbid + + ### + Sends a poke message to the client specified with clid. + ### + clientpoke: (clid, msg, cb) => + if typeof msg == "function" + cb = msg + msg = null + @once "message.error", (args) => cb? checkError(args) + @send "clientpoke", + msg: msg + clid: clid + + clientunmute: (cb) => + throw new Error "Not implemented yet" + + ### + Sets one or more values concerning your own client, and makes them available + to other clients through the server where applicable. Available idents are: + + client_nickname: set a new nickname + client_away: 0 or 1, set us away or back available + client_away_message: what away message to display when away + client_input_muted: 0 or 1, mutes or unmutes microphone + client_output_muted: 0 or 1, mutes or unmutes speakers/headphones + client_input_deactivated: 0 or 1, same as input_muted, but invisible to + other clients + client_is_channel_commander: 0 or 1, sets or removes channel commander + client_nickname_phonetic: set your phonetic nickname + client_flag_avatar: set your avatar + client_meta_data: any string that is passed to all clients that + have vision of you. + client_default_token: privilege key to be used for the next server + connect + ### + clientupdate: (idents, cb) => + @once "message.error", (args) => cb? checkError(args) + @send "clientupdate", idents + + ### + Retrieves client variables from the client (no network usage). For each client + you can specify one or more properties that should be queried, and this whole + block of clientID and properties can be repeated to get information about + multiple clients with one call of clientvariable. + + Available properties are: + client_unique_identifier + client_nickname + client_input_muted + client_output_muted + client_outputonly_muted + client_input_hardware + client_output_hardware + client_meta_data + client_is_recording + client_database_id + client_channel_group_id + client_servergroups + client_away + client_away_message + client_type + client_flag_avatar + client_talk_power + client_talk_request + client_talk_request_msg + client_description + client_is_talker + client_is_priority_speaker + client_unread_messages + client_nickname_phonetic + client_needed_serverquery_view_power + client_icon_id + client_is_channel_commander + client_country + client_channel_group_inherited_channel_id + client_flag_talking + client_is_muted + client_volume_modificator + + These properties are always available for yourself, but need to be requested + for other clients. Currently you cannot request these variables via + clientquery: + client_version + client_platform + client_login_name + client_created + client_lastconnected + client_totalconnections + client_month_bytes_uploaded + client_month_bytes_downloaded + client_total_bytes_uploaded + client_total_bytes_downloaded + + These properties are available only for yourself: + client_input_deactivated + ### + clientvariable: (clid, variables, cb) => + if not clid + throw new Error "Need client ID" + if not Array.isArray variables + throw new Error "variables needs to be an array of requested client variables." + retval = { } + @once "vars", (args) => merge retval, args + @once "message.error", (args) => cb? checkError(args), retval + @send "clientvariable", { clid: clid }, variables + + complainadd: (cb) => + throw new Error "Not implemented yet" + + complaindel: (cb) => + throw new Error "Not implemented yet" + + complaindelall: (cb) => + throw new Error "Not implemented yet" + + complainlist: (cb) => + throw new Error "Not implemented yet" + + ### + Get server connection handler ID of current server tab. + ### + currentschandlerid: (cb) => + retval = { } + @once "vars", (args) => merge retval, args + @once "message.error", (args) => cb? checkError(args), retval + @send "currentschandlerid" + + disconnect: (cb) => close(cb) + + exam: (cb) => + throw new Error "Not implemented yet" + + ftcreatedir: (cb) => + throw new Error "Not implemented yet" + + ftdeletefile: (cb) => + throw new Error "Not implemented yet" + + ftgetfileinfo: (cb) => + throw new Error "Not implemented yet" + + ftgetfilelist: (cb) => + throw new Error "Not implemented yet" + + ftinitdownload: (cb) => + throw new Error "Not implemented yet" + + ftinitupload: (cb) => + throw new Error "Not implemented yet" + + ftlist: (cb) => + throw new Error "Not implemented yet" + + ftrenamefile: (cb) => + throw new Error "Not implemented yet" + + ftstop: (cb) => + throw new Error "Not implemented yet" + + hashpassword: (cb) => + throw new Error "Not implemented yet" + + help: (cb) => + throw new Error "Not implemented yet" + + messageadd: (cb) => + throw new Error "Not implemented yet" + + messagedel: (cb) => + throw new Error "Not implemented yet" + + messageget: (cb) => + throw new Error "Not implemented yet" + + messagelist: (cb) => + throw new Error "Not implemented yet" + + messageupdateflag: (cb) => + throw new Error "Not implemented yet" + + permoverview: (cb) => + throw new Error "Not implemented yet" + + quit: (cb) => close(cb) + + ### + Sends a text message a specified target. The type of the target is determined + by targetmode. + Available targetmodes are: + 1: Send private text message to a client. You must specify the target parameter + 2: Send message to the channel you are currently in. Target is ignored. + 3: Send message to the entire server. Target is ignored. + ### + sendtextmessage: (targetmode, target, msg, cb) => + if targetmode != 1 and (not msg or typeof msg == "function") + cb = msg + msg = target + target = null + @once "message.error", (args) => cb? checkError(args) + @send "sendtextmessage", + targetmode: targetmode + target: target + msg: msg + + serverconnectinfo: (cb) => + throw new Error "Not implemented yet" + + serverconnectionhandlerlist: (cb) => + throw new Error "Not implemented yet" + + servergroupadd: (cb) => + throw new Error "Not implemented yet" + + servergroupaddclient: (cb) => + throw new Error "Not implemented yet" + + servergroupaddperm: (cb) => + throw new Error "Not implemented yet" + + servergroupclientlist: (cb) => + throw new Error "Not implemented yet" + + servergroupdel: (cb) => + throw new Error "Not implemented yet" + + servergroupdelclient: (cb) => + throw new Error "Not implemented yet" + + servergroupdelperm: (cb) => + throw new Error "Not implemented yet" + + servergrouplist: (cb) => + throw new Error "Not implemented yet" + + servergrouppermlist: (cb) => + throw new Error "Not implemented yet" + + servergroupsbyclientid: (cb) => + throw new Error "Not implemented yet" + + servervariable: (cb) => + throw new Error "Not implemented yet" + + setclientchannelgroup: (cb) => + throw new Error "Not implemented yet" + + tokenadd: (cb) => + throw new Error "Not implemented yet" + + tokendelete: (cb) => + throw new Error "Not implemented yet" + + tokenlist: (cb) => + throw new Error "Not implemented yet" + + ### + Use a token key gain access to a server or channel group. Please note that the + server will automatically delete the token after it has been used. + ### + tokenuse: (token, cb) => + @once "message.error", (args) => cb? checkError(args) + @send "tokenuse", + token: token + + ### + Selects the server connection handler scHandlerID or, if no parameter is given, + the currently active server connection handler is selected. + ### + use: (schandlerid, cb) => + retval = { } + @once "message.selected", (args) => merge retval, args + @once "message.error", (args) => cb? checkError(args), retval + @send "use", + schandlerid: schandlerid + + verifychannelpassword: (cb) => + throw new Error "Not implemented yet" + + ### + Verifies the server password and will return an error if the password is + incorrect. + ### + verifyserverpassword: (password, cb) => + @once "message.error", (args) => cb? checkError(args) + @send "verifyserverpassword", + password: password + + ### + Retrieves information about ourself: + - ClientID (if connected) + - ChannelID of the channel we are in (if connected) + + If not connected, an error is returned. + ### + whoami: (cb) => + retval = { } + @once "vars", (args) => merge retval, args + @once "message.error", (args) => cb? checkError(args), retval + @send "whoami" diff --git a/ts3settings.iced b/ts3settings.iced new file mode 100644 index 0000000..c673aac --- /dev/null +++ b/ts3settings.iced @@ -0,0 +1,223 @@ +sqlite3 = require("sqlite3") #.verbose() +SQLite3Database = sqlite3.Database +path = require "path" +mkdirp = require "mkdirp" +SimpleIni = require "simple-ini" +fs = require "fs" +merge = require "merge" +getLogger = require "./logger" + +# some properties sugar from http://bl.ocks.org/joyrexus/65cb3780a24ecd50f6df +Function::getter = (prop, get) -> + Object.defineProperty @prototype, prop, {get, configurable: yes} +Function::setter = (prop, set) -> + Object.defineProperty @prototype, prop, {set, configurable: yes} + +module.exports = class SettingsFile + db: null + identities: null + defaultIdentity: null + + constructor: (@configPath) -> + @log = getLogger "TS3Settings" + + try + mkdirp.sync @configPath + catch err + throw new Error "Could not create TS3 config directory." + + @getter "isInitialized", -> () => fs.existsSync(path.join(@configPath, "settings.db")) and fs.existsSync(path.join(@configPath, "ts3clientui_qt.secrets.conf")) + @getter "isReady", -> () => @db != null + + open: (cb) => + # settings database + @db = new SQLite3Database path.join(@configPath, "settings.db") + await @db.serialize defer() + await @query "CREATE TABLE IF NOT EXISTS TS3Tables (key varchar NOT NULL UNIQUE,timestamp integer unsigned NOT NULL)", defer() + + # secrets file + @identities = [] + @defaultIdentity = null + secretsPath = path.join(@configPath, "ts3clientui_qt.secrets.conf") + if fs.existsSync(secretsPath) + secrets = new SimpleIni (() => fs.readFileSync(secretsPath, "utf-8")), + quotedValues: false + for i in [1 .. secrets.Identities.size] + @identities.push + id: secrets.Identities["#{i}/id"] + identity: secrets.Identities["#{i}/identity"] + nickname: secrets.Identities["#{i}/nickname"] + @defaultIdentity = secrets.Identities.SelectedIdentity + cb?() + + close: (cb) => + if not @isReady + @log.warn "Tried to close TS3 settings when already closed" + return + + await @db.close defer() + + # Build secrets INI structure + secrets = new SimpleIni null, + quotedValues: false + secrets.General = {} + secrets.Bookmarks = + size: 0 + secrets.Identities = + size: @identities.length + index = 1 + for identity in @identities + for key, value of identity + secrets.Identities["#{index}/#{key}"] = value + index++ + if @defaultIdentity + secrets.Identities.SelectedIdentity = @defaultIdentity + + # Generate INI content + await secrets.save defer(iniText) + fs.writeFileSync path.join(@configPath, "ts3clientui_qt.secrets.conf"), iniText + + @identities = null + @defaultIdentity = null + @db = null + + cb?() + + setMultiple: (sets, cb) => + for set in sets + await @set set[0], set[1], set[2], defer(err) + if err + throw err + cb?() + + set: (table, key, value, cb) => + if not @isReady + throw new Error "You need to run open on this instance of TS3Settings first" + return + + if not table + throw new Error "Need table" + + await @query "create table if not exists #{table} (timestamp integer unsigned NOT NULL, key varchar NOT NULL UNIQUE, value varchar)", defer() + + if not key + return + + if not (typeof value == "string" || value instanceof String) + # serialize from object to ts3 dict text + strval = "" + for own k of value + strval += k + "=" + value[k] + "\n" + value = strval + + timestamp = Math.round (new Date).getTime() / 1000 + + stmt = @db.prepare "insert or replace into TS3Tables (key, timestamp) values (?, ?)" + stmt.run table, timestamp + await stmt.finalize defer() + + stmt = @db.prepare "insert or replace into #{table} (timestamp, key, value) values (?, ?, ?)" + stmt.run timestamp, key, value + await stmt.finalize defer() + + cb?() + + query: (stmt, cb) => + if not @isReady + throw new Error "You need to run open on this instance of TS3Settings first" + return + + await @db.run stmt, defer() + cb?() + + importIdentity: (identityFilePath, cb) => + if not @isReady + throw new Error "You need to run open on this instance of TS3Settings first" + return + + if not identityFilePath + throw new Error "Need identity file path" + + @log.info "Importing identity from #{identityFilePath}..." + + # open identity file + idFile = new SimpleIni (() => fs.readFileSync(identityFilePath, "utf-8")), + quotedValues: true + importedIdentity = {} + for own k, v of idFile.Identity + importedIdentity[k] = v + + for identity in @identities + if identity.id == importedIdentity.id + throw new Error "Identity with same ID already exists" + + @identities.push importedIdentity + @log.info "Identity #{importedIdentity.id} imported successfully!" + + cb? @constructIdentityObject importedIdentity + + importIdentitySync: (identityFilePath) => + await @importIdentity identityFilePath, defer retval + return retval + + getIdentities: (cb) => + if not @isReady + throw new Error "You need to run open on this instance of TS3Settings first" + return + + identities = [] + + for identity in @identities + identities.push @constructIdentityObject identity + + cb? identities + + getIdentitiesSync: () => + await @getIdentities defer retval + return retval + + getIdentitiesSize: () => + if not @isReady + throw new Error "You need to run open on this instance of TS3Settings first" + return + + @identities.length + + getSelectedIdentity: () => + if not @isReady + throw new Error "You need to run open on this instance of TS3Settings first" + return + + if not @defaultIdentity + return null + + for own index, identity of @identities + if identity.id == @defaultIdentity + return @constructIdentityObject identity + + clearIdentities: () => + if not @isReady + throw new Error "You need to run open on this instance of TS3Settings first" + return + + @log.debug "Clearing all identities" + @identities.length = 0 + return + + constructIdentityObject: (id) => + settingsObj = @ + clonedId = merge(true, id) + return merge clonedId, # true causes object to be cloned + select: () -> + settingsObj.defaultIdentity = @id + update: () -> + for own index, identity of settingsObj.identities + if identity.id == id.id + settingsObj.identities[index] = merge identity, id + return + remove: () -> + for own index, identity of settingsObj.identities + if identity.id == id.id + delete settingsObj.identities[index] + break + # TODO: Select another identity as default \ No newline at end of file diff --git a/x11.iced b/x11.iced new file mode 100644 index 0000000..c679dca --- /dev/null +++ b/x11.iced @@ -0,0 +1,76 @@ +Sync = require "sync" + +log = require("./logger")("X11tools") +spawn = require("child_process").spawn +services = require("./services") +StreamSplitter = require("stream-splitter") +require_bin = require("./require_bin") + +xdotoolBinPath = require_bin "xdotool" + +# Just some tools to work with the X11 windows +module.exports = + getWindowIdByProcessId: (pid, cb) => + wid = null + + # We provide --name due to the bug mentioned at https://github.com/jordansissel/xdotool/issues/14 + xdoproc = spawn xdotoolBinPath, [ "search", "--any", "--pid", pid, "--name", "xdosearch" ], + env: + DISPLAY: process.env.DISPLAY + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR + stdoutTokenizer = xdoproc.stdout.pipe StreamSplitter "\n" + stdoutTokenizer.encoding = "utf8"; + stdoutTokenizer.on "token", (token) => + token = token.trim() # get rid of \r + newWid = parseInt(token) + if newWid != 0 and wid == null + wid = newWid + stderrTokenizer = xdoproc.stderr.pipe StreamSplitter "\n" + stderrTokenizer.encoding = "utf8"; + stderrTokenizer.on "token", (token) => + token = token.trim() # get rid of \r + log.warn token + await xdoproc.on "exit", defer(e) + + if e.code + log.error "Failed to find window ID, error code #{e.code}" + err = new Error "Failed to find window ID." + cb? err + return + + cb? null, parseInt(wid) + + getWindowIdByProcessIdSync: (pid) => Sync() => @getWindowIdByProcessId.sync @, pid + + sendKeys: (wid, keys, cb) => + # blackbox needs to be running for windowactivate to work + blackboxService = services.find("BlackBox") + if blackboxService.state != "started" + await blackboxService.start defer(err) + if err + cb? new Error "Could not start compatible window manager." + return + + xdoproc = spawn xdotoolBinPath, [ "windowactivate", "--sync", wid, "key", "--clearmodifiers", "--delay", "100" ].concat(keys), + env: + DISPLAY: process.env.DISPLAY + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR + stdoutTokenizer = xdoproc.stdout.pipe StreamSplitter "\n" + stdoutTokenizer.encoding = "utf8"; + stdoutTokenizer.on "token", (token) => + token = token.trim() # get rid of \r + log.debug token + stderrTokenizer = xdoproc.stderr.pipe StreamSplitter "\n" + stderrTokenizer.encoding = "utf8"; + stderrTokenizer.on "token", (token) => + token = token.trim() # get rid of \r + log.warn token + await xdoproc.on "exit", defer(e) + + err = null + if e.code + log.error "Failed to send keys, error code #{e.code}" + err = new Error "Failed to send keys." + cb? err + + sendKeysSync: (keys) => Sync () => @sendKeys.sync @, keys \ No newline at end of file