git-subtree-dir: src
git-subtree-mainline: 14edb2b1bb
git-subtree-split: 4ab1de5a5e
develop
Icedream 2016-05-08 23:32:09 +02:00
commit 5fe924b784
21 changed files with 2696 additions and 0 deletions

28
src/.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
node_modules

64
src/README.md Normal file
View File

@ -0,0 +1,64 @@
# TS3Bot
A new and easy way to set up your own TeamSpeak 3 bot!
This repository contains the Node.js/IcedCoffeeScript code needed for the TS3Bot Docker image.
## Running TS3Bot without Docker
Basically these instructions are derived from [the Docker build files](https://github.com/icedream/ts3bot-docker).
You can adopt these depending on your OS (the Docker image uses Debian Jessie, so the instructions below are for
that OS).
We assume that we want to run the "develop" branch of TS3Bot here. You can easily replace "develop" with another branch you want to run, like "master" for the stable code.
We create a separate user "ts3bot" for this bot using the command below - do not run this on your own user if you use TeamSpeak3 on it as the bot will overwrite the configuration of the client later!
# adduser --disabled-login --disabled-password ts3bot
And we access the user's shell usually via:
# sudo -u ts3bot -s -H
Commands being run as your bot user (`ts3bot`) are marked with `$` and commands being run as root are marked with `#`.
- Install the dependencies, optionally add `git` if you are going to use the git client for cloning the source code later:
# apt-get install node-dev blackbox xvfb xdotool pulseaudio pulseaudio-utils cmake libvlc-dev vlc-plugin-pulse
- Download and unpack TeamSpeak3 client for your platform into a folder accessible by the TS3Bot user. Only read access is required. Replace `3.0.18.2` with whatever version of TeamSpeak3 you prefer to install, usually that is the most recent one. Accept the license that shows up in the process. Also replace `amd64` with `x86` if you're on a 32-bit system.
$ cd ~
$ wget -Ots3client.run http://dl.4players.de/ts/releases/3.0.18.2/TeamSpeak3-Client-linux_amd64-3.0.18.2.run
$ chmod +x ts3client.run
$ ./ts3client.run --target ~ts3bot/ts3client
$ rm ts3client.run
- Download the TS3Bot control application into your TS3Bot user's home folder. The TS3Bot user itself only needs read access to the code. You can do this in two ways:
o By downloading the tar.gz archive from GitHub and unpacking it.
$ wget -q -O- https://github.com/icedream/ts3bot-control/archive/develop.tar.gz | tar xz -C ~
o By cloning the Git repository from GitHub.
$ git clone https://github.com/icedream/ts3bot-control -b develop ~/ts3bot-control-develop
- Install the Node.js dependencies using `npm`. Note how a simple `npm install` will install the wrong version of WebChimera.js and you need to provide it with correct Node.js information (environment variables `npm_config_wcjs_runtime` and `npm_config_wcjs_runtime_version`) like this:
$ cd ~ts3bot/ts3bot-control-develop
$ npm_config_wcjs_runtime="node" npm_config_wcjs_runtime_version="$(node --version | tr -d 'v')" npm install
- Now set up your TS3Bot configuration files in your TS3Bot user's home folder. For this create a folder `.ts3bot` in the home directory and put a `config.json` with your configuration there. The most minimal configuration consists of:
o `identity-path` - The path to your identity INI file, export a newly generated identity from your own TeamSpeak3 client for that.
o `ts3-install-path` - The path where you installed the TeamSpeak3 client to, you can skip this if you used exactly the same path as in the instructions above.
o `ts3-server` - The URL to the server/channel you want the bot to connect to, you can get from your own TS3 client via "Extras" > "Invite friend", select the checkbox "Channel" and select "ts3server link" as invitation type.
Running the bot can finally be done like this:
$ node ~/ts3bot-control-develop
You can provide your configuration as command line arguments instead if you want, that can be useful for just temporary configuration you want to test. For that just append the configuration options to the command line above and prefix every command line option with `--`, for example for `ts3-install-path` you would write `--ts3-install-path=/your/path/to/ts3client`

381
src/app.iced Normal file
View File

@ -0,0 +1,381 @@
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"
temp = require("temp").track()
youtubedl = require "youtube-dl"
isValidUrl = (require "valid-url").isWebUri
parseDuration = require "./parse_duration.iced"
prettyMs = require "pretty-ms"
log = getLogger "Main"
# http://stackoverflow.com/a/7117336
removeBB = (str) -> str.replace /\[(\w+)[^\]]*](.*?)\[\/\1]/g, "$2"
module.exports =
shutdown: (cb) =>
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
# Separate our own PulseAudio from any system one by using our own custom XDG directories.
process.env.XDG_RUNTIME_DIR = temp.mkdirSync "ts3bot-xdg"
# Xvfb for isolated graphical interfaces!
xvfbService = services.find("xvfb")
await xvfbService.start defer err, vlc
if err
if not process.env.DISPLAY? or process.env.DISPLAY.trim() == ""
log.error "Xvfb could not start up and no display is available!", err
await module.exports.shutdown defer()
process.exit 1
log.warn "Xvfb could not start up - will use existing display!", err
# PulseAudio daemon
await services.find("pulseaudio").start defer err
if err
log.warn "PulseAudio could not start up, audio may not act as expected!", err
# VLC via WebChimera.js
vlcService = services.find("vlc")
await vlcService.start defer err, vlc
if err
log.warn "VLC could not start up!", err
await module.exports.shutdown defer()
process.exit 1
# This is where we keep track of the volume
vlcVolume = 50
# Cached information for tracks in playlist
vlcMediaInfo = {}
# TeamSpeak3
ts3clientService = services.find("ts3client")
ts3clientService.on "started", (ts3proc) =>
ts3query = ts3clientService.query
ts3clientService.once "stopped", () =>
ts3query = undefined
# VLC event handling
vlc.onPlaying = () =>
try
# TODO: Check why info is sometimes null, something must be wrong with the "add"/"play" commands here!
# TODO: Do not format as URL in text message if MRL points to local file
item = vlc.playlist.items[vlc.playlist.currentItem]
info = vlcMediaInfo[item.mrl]
url = info?.originalUrl or item.mrl
title = info?.title or item.mrl
ts3query?.sendtextmessage 2, 0, "Now playing [URL=#{url}]#{title}[/URL]."
# Restore audio volume
vlc.audio.volume = vlcVolume
catch e
log.warn "Error in VLC onPlaying handler", e
vlc.onPaused = () => ts3query?.sendtextmessage 2, 0, "Paused."
vlc.onForward = () => ts3query?.sendtextmessage 2, 0, "Fast-forwarding..."
vlc.onBackward = () => ts3query?.sendtextmessage 2, 0, "Rewinding..."
vlc.onEncounteredError = () => log.error "VLC has encountered an error! You will need to restart the bot.", arguments
vlc.onStopped = () => ts3query?.sendtextmessage 2, 0, "Stopped."
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)
if not args.msg?
return
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 "current"
item = vlc.playlist.items[vlc.playlist.currentItem]
if not item?
ts3query?.sendtextmessage args.targetmode, invoker.id, "Not playing anything at the moment."
return
info = vlcMediaInfo[item.mrl]
url = info?.originalUrl or item.mrl
title = info?.title or item.mrl
ts3query?.sendtextmessage args.targetmode, invoker.id, "Currently playing [URL=#{url}]#{title}[/URL]."
# Restore audio volume
vlc.audio.volume = vlcVolume
when "pause"
# now we can toggle-pause playback this easily! yay!
vlc.togglePause()
return
when "play"
inputBB = paramline.trim()
input = (removeBB paramline).trim()
# we gonna interpret play without a url as an attempt to unpause the current song
if input.length <= 0
vlc.play()
return
# only allow playback from file if it's a preconfigured alias
if isValidUrl input
log.debug "Got input URL:", input
else
input = config.get "aliases:#{input}"
if not(isValidUrl input) and not(fs.existsSync input)
log.debug "Got neither valid URL nor valid alias:", input
ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, you're not allowed to play #{inputBB} via the bot."
return
# TODO: permission system to check if uid is allowed to play this url or alias
vlc.playlist.clear()
# let's give youtube-dl a shot!
await youtubedl.getInfo input, [
"--format=bestaudio"
], defer(err, info)
if err or not info?
log.debug "There is no audio-only download for #{inputBB}, downloading full video instead."
await youtubedl.getInfo input, [
"--format=best"
], defer(err, info)
if err or not info?
info =
url: input
if not info.url?
info.url = input
info.title = input # URL as title
info.originalUrl = input
vlcMediaInfo[info.url] = info
# play it in VLC
vlc.play info.url
when "time", "seek", "pos", "position"
inputBB = paramline.trim()
input = (removeBB paramline).trim()
# we gonna interpret no argument as us needing to return the current position
if input.length <= 0
ts3query.sendtextmessage args.targetmode, invoker.id, "Currently position is #{prettyMs vlc.input.time}."
return
ts3query.sendtextmessage args.targetmode, invoker.id, "Seeking to #{prettyMs vlc.input.time}."
vlc.input.time = parseDuration input
return
when "stop-after"
vlc.playlist.mode = vlc.playlist.Single
ts3query.sendtextmessage args.targetmode, invoker.id, "Playback will stop after the current playlist item."
when "loop"
inputBB = paramline
input = null
switch (removeBB paramline).toLowerCase().trim()
when ""
# just show current mode
ts3query.sendtextmessage args.targetmode, invoker.id, "Playlist looping is #{if vlc.playlist.mode == vlc.playlist.Loop then "on" else "off"}."
when "on"
# enable looping
vlc.playlist.mode = vlc.playlist.Loop
ts3query.sendtextmessage args.targetmode, invoker.id, "Playlist looping is now on."
when "off"
# disable looping
vlc.playlist.mode = vlc.playlist.Normal
ts3query.sendtextmessage args.targetmode, invoker.id, "Playlist looping is now off."
else
ts3query.sendtextmessage args.targetmode, invoker.id, "[B]#{name} on|off[/B] - Turns playlist looping on or off"
return
when "next"
if vlc.playlist.items.count == 0
ts3query.sendtextmessage args.targetmode, invoker.id, "The playlist is empty."
return
if vlc.playlist.mode != vlc.playlist.Loop and vlc.playlist.currentItem == vlc.playlist.items.count - 1
ts3query.sendtextmessage args.targetmode, invoker.id, "Can't jump to next playlist item, this is the last one!"
return
vlc.playlist.next()
when "prev", "previous"
if vlc.playlist.items.count == 0
ts3query.sendtextmessage args.targetmode, invoker.id, "The playlist is empty."
return
if vlc.playlist.mode != vlc.playlist.Loop and vlc.playlist.currentItem <= 0
ts3query.sendtextmessage args.targetmode, invoker.id, "Can't jump to previous playlist item, this is the first one!"
return
vlc.playlist.prev()
when "empty", "clear"
vlc.playlist.clear()
ts3query.sendtextmessage args.targetmode, invoker.id, "Cleared the playlist."
when "enqueue", "add", "append"
inputBB = paramline.trim()
input = (removeBB paramline).trim()
if inputBB.length <= 0
ts3query.sendtextmessage args.targetmode, invoker.id, "[B]#{name} <url>[/B] - Adds the specified URL to the current playlist"
return
# only allow playback from file if it's a preconfigured alias
if isValidUrl input
log.debug "Got input URL:", input
else
input = config.get "aliases:#{input}"
if not(isValidUrl input) and not(fs.existsSync input)
log.debug "Got neither valid URL nor valid alias:", input
ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, you're not allowed to play #{inputBB} via the bot."
return
# TODO: permission system to check if uid is allowed to play this url or alias
# let's give youtube-dl a shot!
await youtubedl.getInfo input, [
"--format=bestaudio"
], defer(err, info)
if err or not info?
log.debug "There is no audio-only download for #{inputBB}, downloading full video instead."
await youtubedl.getInfo input, [
"--format=best"
], defer(err, info)
if err or not info?
info =
url: input
if not info.url?
info.url = input
info.title = input # URL as title
info.originalUrl = input
vlcMediaInfo[info.url] = info
# add it in VLC
vlc.playlist.add info.url
ts3query.sendtextmessage args.targetmode, invoker.id, "Added [URL=#{input}]#{info.title}[/URL] to the playlist."
# TODO: Do we need to make sure that vlc.playlist.mode is not set to "Single" here or is that handled automatically?
when "stop"
vlc.stop()
when "vol"
inputBB = paramline.trim()
input = (removeBB paramline).trim()
if inputBB.length <= 0
ts3query.sendtextmessage args.targetmode, invoker.id, "Volume is currently set to #{vlcVolume}%."
return
vol = parseInt input
if paramline.trim().length <= 0 or isNaN(vol) or vol > 200 or vol < 0
ts3query.sendtextmessage args.targetmode, invoker.id, "[B]vol <number>[/B] - takes a number between 0 (0%) and 200 (200%) to set the volume. 100% is 100. Defaults to 50 (50%) on startup."
return
vlc.audio.volume = vlcVolume = vol
ts3query.sendtextmessage args.targetmode, invoker.id, "Volume set to #{vol}%."
when "changenick"
nick = paramline
Sync () =>
try
ts3query.clientupdate.sync ts3query, { client_nickname: nick }
catch err
log.warn "ChangeNick failed, error information:", err
switch err.id
when 513 then ts3query.sendtextmessage args.targetmode, invoker.id, "That nickname is already in use."
when 1541 then ts3query.sendtextmessage args.targetmode, invoker.id, "That nickname is too short or too long."
else ts3query.sendtextmessage args.targetmode, invoker.id, "That unfortunately didn't work out."
await ts3clientService.start [ config.get("ts3-server") ], defer(err, ts3proc)
if err
log.error "TeamSpeak3 could not start, shutting down.", err
await module.exports.shutdown defer()
process.exit 1

79
src/app.js Normal file
View File

@ -0,0 +1,79 @@
#!/usr/bin/env node
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("uncaughtException", function(err) {
log.error("Shutting down due to an uncaught exception!", err);
app.shutdownSync();
process.exit(0xFF);
});
process.on("exit", function(e) {
log.debug("Triggered exit", e);
app.shutdownSync();
});
process.on("SIGTERM", function(e) {
log.debug("Caught SIGTERM signal");
app.shutdown(function() {
process.exit(0);
});
});
process.on("SIGINT", function() {
log.debug("Caught SIGINT signal");
app.shutdown(function() {
process.exit(0);
});
});
process.on("SIGHUP", function() {
log.debug("Caught SIGHUP signal");
app.shutdown(function() {
process.exit(0);
});
});
process.on("SIGQUIT", function() {
log.debug("Caught SIGQUIT signal");
app.shutdown(function() {
process.exit(0);
});
});
process.on("SIGABRT", function() {
log.debug("Caught SIGABRT signal");
app.shutdown(function() {
process.exit(0);
});
});

41
src/config.iced Normal file
View File

@ -0,0 +1,41 @@
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)."
if nconf.get("nickname")? and (nconf.get("nickname").length < 3 or nconf.get("nickname").length > 30)
throw new Error "Nickname must be between 3 and 30 characters long."
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
src/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

55
src/package.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "ts3bot",
"version": "0.3.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",
"bin": {
"ts3bot": "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+",
"repository": {
"type": "git",
"url": "https://github.com/icedream/ts3bot-control.git"
},
"dependencies": {
"express": "^4.13.3",
"iced-coffee-script": "^108.0.8",
"merge": "^1.2.0",
"mkdirp": "^0.5.1",
"named-regexp": "^0.1.1",
"nconf": "^0.7.2",
"npm-which": "^2.0.0",
"parse-duration": "^0.1.1",
"password-generator": "^2.0.1",
"pretty-ms": "^2.1.0",
"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",
"temp": "^0.8.3",
"valid-url": "^1.0.9",
"webchimera.js": "^0.1.38",
"which": "^1.1.2",
"winston": "^1.0.1",
"xvfb": "git://github.com/icedream/node-xvfb.git",
"youtube-dl": "^1.10.5"
}
}

12
src/parse_duration.iced Normal file
View File

@ -0,0 +1,12 @@
parseDuration = require "parse-duration"
namedRegex = require("named-regexp").named
durationRegex = namedRegex /^(((:<h>[0-9]{0,2}):)?(:<m>[0-9]{0,2}):)?(:<s>[0-9]{0,2})(:<ms>\.[0-9]*)?$/
module.exports = (str) ->
# check if this is in the colon-separated format
if str.indexOf(":") > -1 and str.match durationRegex
m = durationRegex.exec(str).matches
return m["ms"] + m["s"]*60 + m["m"]*(60*60) + m["h"]*(60*60*60)
parseDuration str

24
src/require_bin.iced Normal file
View File

@ -0,0 +1,24 @@
which = require("which").sync
path = require "path"
log = require("./logger")("RequireBin")
module.exports = (binName, doErrorIfNotFound) =>
doErrorIfNotFound = true unless doErrorIfNotFound?
# 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
if doErrorIfNotFound
log.error "#{binName} could not be found."
throw new Error "#{binName} could not be found."
else
log.warn "#{binName} could not be found."
return null

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
src/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 false, 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 = serviceArgs
@state = "started"
@emit "started", service
cb? null, service
stop: () => @_stop false, 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 @

69
src/services.iced Normal file
View File

@ -0,0 +1,69 @@
# 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/pulseaudio")
new(require "./services/ts3client")
new(require "./services/vlc")
new(require "./services/xvfb")
new(require "./services/xwm")
]
services.sort require("./service_depcomparer") # sort services by dependency
for service in services
module.exports.register service
module.exports.services = services

View File

@ -0,0 +1,101 @@
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: [
]
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
"--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

300
src/services/ts3client.iced Normal file
View File

@ -0,0 +1,300 @@
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"
fs = require "fs"
url = require "url"
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: [
"pulseaudio"
]
constructor: -> super "TS3Client",
start: (args, cb) =>
if not process.env.XDG_RUNTIME_DIR? or process.env.XDG_RUNTIME_DIR.trim() == ""
cb? new Error "XDG runtime directory needs to be set."
return
if not process.env.DISPLAY? or process.env.DISPLAY.trim() == ""
cb? new Error "There is no display to run TeamSpeak3 on."
return
if typeof args == "function"
cb = args
args = null
if @process
cb? null, @process
return
uri = null
if not args
args = []
for v in args
if v.indexOf("ts3server:") != 0
continue
uri = v
break
await fs.access ts3client_binpath, fs.R_OK | fs.X_OK, defer err
if err
log.error "Can't access TeamSpeak3 client binary at #{ts3client_binpath}, does the binary exist and have you given correct access?"
cb? new Error "Access to TeamSpeak3 binary failed."
return
await @_preconfigure uri, 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) =>
log.warn "Error in TS3 query connection", err
@query.on "close", =>
if not @_requestedExit
log.warn "Connection to TS3 client query interface lost, reconnecting..."
@_queryReconnectTimer = setTimeout @query.connect.bind(@query), 1000
else
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: (uriString, cb) =>
uri = url.parse(uriString, true, true) if uriString?
ts3settings = new TS3Settings config.get("ts3-config-path")
await ts3settings.open defer()
# Delete bookmarks to prevent auto-connect bookmarks from weirding out the client
await ts3settings.query "delete * from Bookmarks", defer()
# Delete all profiles so we can enforce our own
await ts3settings.query "delete * from Profiles", 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
now = new Date()
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" ]
[ "ClientLogView", "LogLevel", "000001" ]
[ "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" ]
[ "News", "LastModified", now.toISOString() ]
[ "News", "NextCheck", new Date(now.getTime() + 1000 * 60 * 60 * 24 * 365).toISOString() ]
[ "Notifications", "SoundPack", "nosounds" ]
[ "Plugins", "teamspeak_control_plugin", "false" ]
[ "Plugins", "clientquery_plugin", "true" ]
[ "Plugins", "lua_plugin", "false" ]
[ "Plugins", "test_plugin", "false" ]
[ "Plugins", "ts3g15", "false" ]
# Intentionally leaving out DefaultPlaybackProfile so TS3Client will trick itself into using the blank profile
[ "Profiles", "Playback/", {} ]
[ "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
} ]
[ "Statistics", "ParticipateStatistics", "0" ]
[ "Statistics", "ConfirmedParticipation", "1" ]
], defer()
if uri?
bookmarkId = "{5125344e-45ec-4510-9bbf-8b940628c5d0}"
bookmarkData =
Name: uriString
Address: uri.hostname or "localhost"
Port: uri.port or 9987
CaptureProfile: "Default"
PlaybackProfile: ""
Identity: "Standard"
Nick: uri.nickname or config.get("nickname") or "TS3Bot"
Autoconnect: false
ShowServerQueryClients: false
Uuid: bookmarkId
bookmarkData.ServerUID = uri.query?.server_uid if uri.query?.server_uid?
await ts3settings.set "Bookmarks", bookmarkId, bookmarkData, defer()
await ts3settings.close defer()
cb?()

45
src/services/vlc.iced Normal file
View File

@ -0,0 +1,45 @@
spawn = require("child_process").spawn
services = require("../services")
config = require("../config")
wc = require("webchimera.js")
StreamSplitter = require("stream-splitter")
module.exports = class VLCService extends services.Service
dependencies: [
"pulseaudio"
]
constructor: -> super "VLC",
###
# Starts an instance of VLC and keeps it ready for service.
###
start: (cb) ->
if @_instance
cb? null, @_instance
return
calledCallback = false
instance = wc.createPlayer [
"--aout", "pulse",
"--no-video"
]
instance.audio.volume = 50
@_instance = instance
cb? null, @_instance
###
# Shuts down the VLC instance.
###
stop: (cb) ->
if not @_instance
cb?()
return
@_instance.stop()
# TODO: Is there even a proper way to shut this down?
@_instance = null
cb?()

50
src/services/xvfb.iced Normal file
View File

@ -0,0 +1,50 @@
Xvfb = require("xvfb")
log = require("../logger")("Xvfb")
config = require("../config")
services = require("../services")
require_bin = require("../require_bin")
xvfbPath = require_bin "Xvfb", false
module.exports = class XvfbService extends services.Service
constructor: -> super "Xvfb",
start: (cb) ->
if not xvfbPath?
cb? new Error "Xvfb is not available."
return
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?()

83
src/services/xwm.iced Normal file
View File

@ -0,0 +1,83 @@
spawn = require("child_process").spawn
log = require("../logger")("XWindowManager")
services = require("../services")
StreamSplitter = require("stream-splitter")
require_bin = require("../require_bin")
xwmBinPath = require_bin "x-window-manager", false
module.exports = class XWindowManagerService extends services.Service
dependencies: [
]
constructor: -> super "XWindowManager",
start: (cb) ->
if not xwmBinPath?
cb? new Error "A window manager not available."
return
if not process.env.XDG_RUNTIME_DIR? or process.env.XDG_RUNTIME_DIR.trim() == ""
cb? new Error "XDG runtime directory needs to be set."
return
if not process.env.DISPLAY? or process.env.DISPLAY.trim() == ""
cb? new Error "There is no display to run TeamSpeak3 on."
return
if @process
cb? null, @process
return
calledCallback = false
proc = null
doStart = null
doStart = () =>
proc = spawn xwmBinPath, [],
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 "Window manager terminated unexpectedly during startup."
cb? new Error "Window manager terminated unexpectedly."
@log.warn "Window manager 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?()

13
src/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 = {}

823
src/ts3query.iced Normal file
View File

@ -0,0 +1,823 @@
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, "\\p")\
.replace(/\n/g, "\\n")\
.replace(/\r/g, "\\r")\
.replace(/\t/g, "\\t")\
.replace(/\ /g, "\\s")
unescape = (value) => value.toString()\
.replace(/\\s/g, " ")\
.replace(/\\t/g, "\t")\
.replace(/\\r/g, "\r")\
.replace(/\\n/g, "\n")\
.replace(/\\p/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", () =>
@_stopKeepalive()
@emit "close"
@_tcpClient.on "error", (err) =>
@_log.warn "Connection error", err
@_stopKeepalive()
@_tcpClient = null
@emit "error", err
@emit "connecting"
@_tcpClient.connect @_port, @_host, () =>
@emit "open"
await @once "message.selected", defer(selectedArgs)
# send keepalives to avoid connection timeout
@_resetKeepalive()
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:", token
if response.name
@emit "message.#{response.name}", response.args
else
@emit "vars", response.args
_sendKeepalive: (cb) =>
@_log.silly "Send: <keep-alive>"
@_tcpClient.write "\n\r", "utf8", () => cb?()
_stopKeepalive: () =>
if @_keepaliveInt?
clearInterval @_keepaliveInt
@_keepaliveInt = null
@_log.silly "Stopped keep-alive."
return
@_log.silly "Requested to stop keep-alive sending but it's already stopped!"
_resetKeepalive: () =>
@_stopKeepalive()
@_log.silly "Setting up keep-alive."
@_keepaliveInt = setInterval @_sendKeepalive, 60000
close: (cb) =>
@_stopKeepalive()
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:", text.trim()
@_tcpClient.write text, "utf8", () => cb?()
@_resetKeepalive()
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"

232
src/ts3settings.iced Normal file
View File

@ -0,0 +1,232 @@
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: () ->
settingsObj.log.silly "Requested update of #{id.id}"
for own index, identity of settingsObj.identities
if identity.id == id.id
# remove functions from this object
cleanIdentity = merge @
for own k, v of cleanIdentity
if typeof v == "function"
delete cleanIdentity[k]
# now this is our new identity object!
settingsObj.log.silly "Updating identity #{id.id}"
settingsObj.identities[index] = cleanIdentity
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

86
src/x11.iced Normal file
View File

@ -0,0 +1,86 @@
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", false
# Just some tools to work with the X11 windows
module.exports =
getWindowIdByProcessId: (pid, cb) =>
wid = null
# Return null instantly if xdotool is not available
if not xdotoolBinPath?
cb? new Error "xdotool is not available"
return
# 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) =>
# Do not bother trying if xdotool is not available
if not xdotoolBinPath?
cb? new Error "xdotool not available."
return
# a window manager needs to be running for windowactivate to work
xwmService = services.find("XWindowManager")
if xwmService.state != "started"
await xwmService.start defer(err)
if err
cb? new Error "Could not start a 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