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
Icedream 2015-10-15 17:25:10 +02:00
commit a0bded664a
20 changed files with 2399 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
iced

194
app.iced Normal file
View File

@ -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

61
app.js Normal file
View File

@ -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();
});

38
config.iced Normal file
View File

@ -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"

50
logger.iced Normal file
View File

@ -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

43
package.json Normal file
View File

@ -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"
}
}

18
require_bin.iced Normal file
View File

@ -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."

6
service_depcomparer.iced Normal file
View File

@ -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

154
service_template.iced Normal file
View File

@ -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 @

70
services.iced Normal file
View File

@ -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

95
services/api.iced Normal file
View File

@ -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?()

76
services/blackbox.iced Normal file
View File

@ -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?()

103
services/pulseaudio.iced Normal file
View File

@ -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

264
services/ts3client.iced Normal file
View File

@ -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?()

87
services/vlc.iced Normal file
View File

@ -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?()

46
services/xvfb.iced Normal file
View File

@ -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?()

13
sugar_property.iced Normal file
View File

@ -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 = {}

781
ts3query.iced Normal file
View File

@ -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"

223
ts3settings.iced Normal file
View File

@ -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

76
x11.iced Normal file
View File

@ -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