mirror of https://github.com/icedream/ts3bot.git
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.develop
commit
a0bded664a
|
@ -0,0 +1 @@
|
|||
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
|
|
@ -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();
|
||||
});
|
|
@ -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"
|
|
@ -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
|
|
@ -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 <icedream2k9@die-optimisten.net>",
|
||||
"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"
|
||||
}
|
||||
}
|
|
@ -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."
|
|
@ -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
|
|
@ -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 @
|
|
@ -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
|
|
@ -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?()
|
|
@ -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?()
|
|
@ -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
|
|
@ -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?()
|
|
@ -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?()
|
||||
|
|
@ -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?()
|
|
@ -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 = {}
|
|
@ -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"
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue