mirror of https://github.com/icedream/ts3bot.git
Add 4ab1de5a5e
to 'src/'.
git-subtree-dir: src git-subtree-mainline:develop14edb2b1bb
git-subtree-split:4ab1de5a5e
commit
5fe924b784
|
@ -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
|
|
@ -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`
|
|
@ -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
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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"
|
|
@ -0,0 +1,50 @@
|
|||
winston = require "winston"
|
||||
path = require "path"
|
||||
config = require "./config"
|
||||
merge = require "merge"
|
||||
winstonCommon = require "winston/lib/winston/common"
|
||||
|
||||
winston.emitErrs = true
|
||||
|
||||
transports = []
|
||||
|
||||
# console logging
|
||||
console.log "Minimal logging level for console is #{config.get("console-log-level")}"
|
||||
transports.push new (winston.transports.Console)
|
||||
colorize: not config.get("json")
|
||||
silent: config.get("quiet") or config.get("silent") or false
|
||||
json: config.get("json") or false
|
||||
stringify: config.get("json") and config.get("json-stringify") or false
|
||||
timestamp: config.get("timestamp") or false
|
||||
debugStdout: not config.get("debug-stderr")
|
||||
prettyPrint: not config.get("json")
|
||||
level: config.get("console-log-level")
|
||||
|
||||
# file logging
|
||||
if not config.get("disable-file-logging")
|
||||
transports.push new (winston.transports.File)
|
||||
filename: path.join config.get("log-path"), "#{config.get("environment")}.log"
|
||||
tailable: true
|
||||
zippedArchive: config.get("zip-logs") or false
|
||||
level: config.get("file-log-level")
|
||||
if config.get("json")
|
||||
transports.push new (winston.transports.File)
|
||||
filename: path.join config.get("log-path"), "#{config.get("environment")}.json"
|
||||
json: true
|
||||
tailable: true
|
||||
zippedArchive: config.get("zip-logs") or false
|
||||
level: config.get("file-log-level")
|
||||
|
||||
container = new (winston.Container)
|
||||
transports: transports
|
||||
|
||||
initialized_loggers = []
|
||||
|
||||
module.exports = (name, options) =>
|
||||
if not(name in initialized_loggers)
|
||||
logger = container.add name
|
||||
logger.addFilter (msg, meta, level) => "[#{name}] #{msg}"
|
||||
initialized_loggers.push name
|
||||
return logger
|
||||
|
||||
container.get name
|
|
@ -0,0 +1,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"
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = (a, b) ->
|
||||
if a.dependencies.indexOf(b.name) >= 0
|
||||
return -1; # a before b
|
||||
if b.dependencies.indexOf(a.name) >= 0
|
||||
return 1; # a after b
|
||||
return 0; # does not matter
|
|
@ -0,0 +1,154 @@
|
|||
Sync = require "sync"
|
||||
|
||||
getLogger = require "./logger"
|
||||
EventEmitter = require("events").EventEmitter
|
||||
merge = require "merge"
|
||||
services = require "./services"
|
||||
|
||||
module.exports = class Service extends EventEmitter
|
||||
constructor: (@name, funcs) ->
|
||||
@log = getLogger @name
|
||||
@_funcs = funcs
|
||||
@_funcs.log = @log # for bind lovers and coffeescript fat arrow (=>) lovers
|
||||
|
||||
@on "started", => @emit "_ready"
|
||||
@on "stopped", => @emit "_ready"
|
||||
|
||||
if not @dependencies
|
||||
@dependencies = []
|
||||
|
||||
state: "stopped"
|
||||
|
||||
start: () => @_start 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 @
|
|
@ -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
|
|
@ -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
|
|
@ -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?()
|
|
@ -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?()
|
||||
|
|
@ -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?()
|
|
@ -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?()
|
|
@ -0,0 +1,13 @@
|
|||
# @property "prop", { desc... }
|
||||
Function::property = (prop, desc) -> Object.defineProperty @prototype, prop, desc
|
||||
|
||||
# defineProperty "prop", { desc... }
|
||||
#Object::defineProperty = (prop, desc) -> Object.defineProperty @, prop, desc
|
||||
|
||||
# propertiesof obj
|
||||
#global.propertiesof = (obj) -> Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertyNames(obj.constructor.prototype or {}))
|
||||
|
||||
# descriptorof obj, name
|
||||
#global.descriptorof = (obj, name) -> Object.getOwnPropertyDescriptor obj, name
|
||||
|
||||
module.exports = exports = {}
|
|
@ -0,0 +1,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"
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue