├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .gitmodules ├── Bot.py ├── ClientInfo.py ├── CommandHandler.py ├── EventHandler.py ├── LICENSE ├── Moduleloader.py ├── README.md ├── advanced_permissions.png ├── config.example.ini ├── event_documentation ├── main.py ├── modules ├── Quotes.py ├── afkmover.py ├── phrasendrescher.py └── utils.py ├── requirements.txt └── show_serverquery.png /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 15 * * 2' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['python'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | syntax: glob 2 | .bash_history 3 | __conn_settings__.py 4 | bot.log 5 | afk.log 6 | *.log 7 | *.log.* 8 | *.pyc 9 | *__pycache__/ 10 | quotes 11 | main.sh 12 | .viminfo 13 | .selected_editor 14 | .ssh/ 15 | .vim/ 16 | config.ini 17 | .idea/ 18 | modules/quotes.db 19 | tags 20 | test.py 21 | tests.py 22 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "ts3"] 2 | path = ts3 3 | url = https://github.com/Murgeye/ts3API.git 4 | -------------------------------------------------------------------------------- /Bot.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import logging 3 | import os 4 | from distutils.util import strtobool 5 | 6 | import ts3API.TS3Connection 7 | from ts3API.TS3Connection import TS3QueryException 8 | from ts3API.TS3QueryExceptionType import TS3QueryExceptionType 9 | 10 | import CommandHandler 11 | import EventHandler 12 | import Moduleloader 13 | 14 | 15 | def stop_conn(ts3conn): 16 | ts3conn.stop_recv.set() 17 | 18 | 19 | def send_msg_to_client(ts3conn, clid, msg): 20 | """ 21 | Convenience method for sending a message to a client without having a bot object. 22 | :param ts3conn: TS3Connection to send message on. 23 | :type ts3conn: ts3API.TS3Connection 24 | :param clid: Client id of the client to send too. 25 | :type clid: int 26 | :param msg: Message to send 27 | :type msg: str 28 | :return: 29 | """ 30 | try: 31 | ts3conn.sendtextmessage(targetmode=1, target=clid, msg=msg) 32 | except ts3API.TS3Connection.TS3QueryException: 33 | logger = logging.getLogger("bot") 34 | logger.exception("Error sending a message to clid " + str(clid)) 35 | 36 | 37 | class Ts3Bot: 38 | """ 39 | Teamspeak 3 Bot with module support. 40 | """ 41 | def get_channel_id(self, name): 42 | """ 43 | Covenience method for getting a channel by name. 44 | :param name: Channel name to search for, can be a pattern 45 | :type name: str 46 | :return: Channel id of the first channel found 47 | :rtype: int 48 | """ 49 | ret = self.ts3conn.channelfind(pattern=name) 50 | return int(ret[0]["cid"]) 51 | 52 | @staticmethod 53 | def bot_from_config(config): 54 | """ 55 | Create a bot from the values parsed from config.ini 56 | :param config: a configuration for the bot 57 | :type config: dict 58 | :return: Created Bot 59 | :rtype: Ts3Bot 60 | """ 61 | logger = logging.getLogger("bot") 62 | plugins = config 63 | config = config.pop('General') 64 | return Ts3Bot(logger=logger, plugins=plugins, **config) 65 | 66 | @staticmethod 67 | def parse_config(logger): 68 | """ 69 | Parse the config file config.ini 70 | :param logger: Logger to log errors to. 71 | :return: Dictionary containing options necessary to create a new bot 72 | :rtype: dict[str, dict[str, str]] 73 | """ 74 | config = configparser.ConfigParser() 75 | if len(config.read('config.ini')) == 0: 76 | logger.error("Config file missing!") 77 | exit() 78 | if not config.has_section('General'): 79 | logger.error("Config file is missing general section!") 80 | exit() 81 | if not config.has_section('Plugins'): 82 | logger.error("Config file is missing plugins section") 83 | exit() 84 | return config._sections 85 | 86 | def connect(self): 87 | """ 88 | Connect to the server specified by self.host and self.port. 89 | :return: 90 | """ 91 | try: 92 | self.ts3conn = ts3API.TS3Connection.TS3Connection(self.host, self.port, 93 | use_ssh=self.is_ssh, username=self.user, 94 | password=self.password, accept_all_keys=self.accept_all_keys, 95 | host_key_file=self.host_key_file, 96 | use_system_hosts=self.use_system_hosts, sshtimeout=self.sshtimeout, sshtimeoutlimit=self.sshtimeoutlimit) 97 | # self.ts3conn.login(self.user, self.password) 98 | except ts3API.TS3Connection.TS3QueryException: 99 | self.logger.exception("Error while connecting, IP propably not whitelisted or Login data wrong!") 100 | # This is a very ungraceful exit! 101 | os._exit(-1) 102 | raise 103 | 104 | def setup_bot(self): 105 | """ 106 | Setup routine for new bot. Does the following things: 107 | 1. Select virtual server specified by self.sid 108 | 2. Set bot nickname to the Name specified by self.bot_name 109 | 3. Move the bot to the channel specified by self.default_channel 110 | 4. Register command and event handlers 111 | :return: 112 | """ 113 | try: 114 | self.ts3conn.use(sid=self.sid) 115 | except ts3API.TS3Connection.TS3QueryException: 116 | self.logger.exception("Error on use SID") 117 | exit() 118 | try: 119 | try: 120 | self.ts3conn.clientupdate(["client_nickname=" + self.bot_name]) 121 | except TS3QueryException as e: 122 | if e.type == TS3QueryExceptionType.CLIENT_NICKNAME_INUSE: 123 | self.logger.info("The choosen bot nickname is already in use, keeping the default nickname") 124 | else: 125 | raise e 126 | try: 127 | self.channel = self.get_channel_id(self.default_channel) 128 | self.ts3conn.clientmove(self.channel, int(self.ts3conn.whoami()["client_id"])) 129 | except TS3QueryException as e: 130 | if e.type == TS3QueryExceptionType.CHANNEL_ALREADY_IN: 131 | self.logger.info("The bot is already in the configured default channel") 132 | else: 133 | raise e 134 | except TS3QueryException: 135 | self.logger.exception("Error on setting up client") 136 | self.ts3conn.quit() 137 | return 138 | self.command_handler = CommandHandler.CommandHandler(self.ts3conn) 139 | self.event_handler = EventHandler.EventHandler(ts3conn=self.ts3conn, command_handler=self.command_handler) 140 | try: 141 | self.ts3conn.register_for_server_events(self.event_handler.on_event) 142 | self.ts3conn.register_for_channel_events(0, self.event_handler.on_event) 143 | self.ts3conn.register_for_private_messages(self.event_handler.on_event) 144 | except ts3API.TS3Connection.TS3QueryException: 145 | self.logger.exception("Error on registering for events.") 146 | exit() 147 | 148 | def __del__(self): 149 | if self.ts3conn is not None: 150 | self.ts3conn.quit() 151 | 152 | def __init__(self, host, port, serverid, user, password, defaultchannel, botname, logger, plugins, ssh="False", 153 | acceptallsshkeys="False", sshhostkeyfile=None, sshloadsystemhostkeys="False", sshtimeout=None, sshtimeoutlimit=3, *_, **__): 154 | """ 155 | Create a new Ts3Bot. 156 | :param host: Host to connect to, can be a IP or a host name 157 | :param port: Port to connect to 158 | :param sid: Virtual Server id to use 159 | :param user: Server Query Admin Login Name 160 | :param password: Server Query Admin Password 161 | :param default_channel: Channel to move the bot to 162 | :param bot_name: Nickname of the bot 163 | :param logger: Logger to use throughout the bot 164 | """ 165 | self.host = host 166 | self.port = port 167 | self.user = user 168 | self.password = password 169 | self.sid = serverid 170 | self.default_channel = defaultchannel 171 | self.bot_name = botname 172 | self.event_handler = None 173 | self.command_handler = None 174 | self.channel = None 175 | self.logger = logger 176 | self.ts3conn = None 177 | self.is_ssh = bool(strtobool(ssh)) 178 | # Strtobool returns 1/0 ... 179 | self.accept_all_keys = bool(strtobool(acceptallsshkeys)) 180 | self.host_key_file = sshhostkeyfile 181 | self.use_system_hosts = bool(strtobool(sshloadsystemhostkeys)) 182 | self.sshtimeout = sshtimeout 183 | self.sshtimeoutlimit = sshtimeoutlimit 184 | 185 | self.connect() 186 | self.setup_bot() 187 | # Load modules 188 | Moduleloader.load_modules(self, plugins) 189 | self.ts3conn.start_keepalive_loop() 190 | -------------------------------------------------------------------------------- /ClientInfo.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | logger = logging.getLogger("bot") 5 | 6 | 7 | class ClientInfo: 8 | """ 9 | ClientInfo contains various attributes of the client with the given client id 10 | The attributes in this object have been filtered, if you want to know about all 11 | possible attributes, use print(client_data[0].keys()) 12 | """ 13 | def __init__(self, client_id, ts3conn): 14 | if client_id == "-1": 15 | logger.error("Trying to get ClientInfo of clid=-1") 16 | logger.warning("Giving out mock object ...") 17 | client_data = [{}] 18 | else: 19 | client_data = ts3conn.clientinfo(client_id) 20 | self._name = client_data.get('client_nickname', '') 21 | self._unique_id = client_data.get('client_unique_identifier', '') 22 | self._database_id = client_data.get('client_database_id', '') 23 | # servergroups is a list of strings 24 | sgs = {} 25 | for g in ts3conn.servergrouplist(): 26 | sgs[g.get('sgid')] = g.get('name') 27 | servergroups_list = client_data.get('client_servergroups', '').split(',') 28 | self._servergroups = [] 29 | for g in servergroups_list: 30 | n = sgs.get(g) 31 | if n is not None: 32 | self._servergroups.append(n) 33 | self._description = client_data.get('client_description', '') 34 | self._country = client_data.get('client_country', '') 35 | self._created = client_data.get('client_created', '') 36 | self._total_connections = client_data.get('client_totalconnections', '') 37 | self._last_connection = client_data.get('client_lastconnected', '') 38 | self._connected_time = client_data.get('connection_connected_time', '') 39 | self._platform = client_data.get('client_platform', '') 40 | self._version = client_data.get('client_version', '') 41 | self._ip = client_data.get('connection_client_ip', '') 42 | self._away = client_data.get('client_away', '') 43 | self._input_muted = client_data.get('client_input_muted', '') 44 | self._output_muted = client_data.get('client_output_muted', '') 45 | self._outputonly_muted = client_data.get('client_outputonly_muted', '') 46 | self._input_hardware = client_data.get('client_input_hardware', '') 47 | self._output_hardware = client_data.get('client_output_hardware', '') 48 | self._channel_id = client_data.get('cid', '-1') 49 | if len(servergroups_list) == 0: 50 | logger.error("Client without servergroups parsed ...") 51 | logger.error("IP: " + self.ip + "Name: " + self.name + "channel_id: " + self.channel_id) 52 | logger.error(str(client_data)) 53 | 54 | @property 55 | def channel_id(self): 56 | return self._channel_id 57 | 58 | @property 59 | def ip(self): 60 | return self._ip 61 | 62 | @property 63 | def name(self): 64 | return self._name 65 | 66 | @property 67 | def servergroups(self): 68 | return self._servergroups 69 | 70 | def is_in_servergroups(self, pattern): 71 | for g in self._servergroups: 72 | if re.search(pattern=pattern, string=g) is not None: 73 | return True 74 | return False 75 | 76 | def __getattr__(self, item): 77 | return self.__getattribute__("_"+item) 78 | -------------------------------------------------------------------------------- /CommandHandler.py: -------------------------------------------------------------------------------- 1 | """Commandhandler for the Teamspeak3 Bot.""" 2 | import logging 3 | 4 | import ts3API.Events as Events 5 | 6 | import Bot 7 | import ClientInfo 8 | 9 | logger = logging.getLogger("bot") 10 | 11 | 12 | class CommandHandler: 13 | """ 14 | Command handler class that listens for PrivateMessages and informs registered handlers of possible commands. 15 | """ 16 | def __init__(self, ts3conn): 17 | """ 18 | Create new CommandHandler. 19 | :param ts3conn: TS3Connection to use 20 | """ 21 | self.ts3conn = ts3conn 22 | self.logger = logging.getLogger("textMsg") 23 | self.logger.setLevel(logging.INFO) 24 | file_handler = logging.FileHandler("msg.log", mode='a+') 25 | formatter = logging.Formatter('MSG Logger %(asctime)s %(message)s') 26 | file_handler.setFormatter(formatter) 27 | self.logger.addHandler(file_handler) 28 | self.logger.propagate = 0 29 | self.handlers = {} 30 | # Default groups if group not specified. 31 | self.accept_from_groups = ['Server Admin', 'Moderator'] 32 | 33 | def add_handler(self, handler, command): 34 | """ 35 | Add a handler for a command. 36 | :param handler: Handler function to add. 37 | :type handler: function 38 | :param command: Command to handle. 39 | :type command: str 40 | """ 41 | if self.handlers.get(command) is None: 42 | self.handlers[command] = [handler] 43 | else: 44 | self.handlers[command].append(handler) 45 | 46 | def check_permission(self, handler, clientinfo): 47 | """ 48 | Check if the client is allowed to call this command for this handler. 49 | :param handler: Handler function to check permissions for. 50 | :param clientinfo: Client info of the client that tries to use the command. 51 | :return: 52 | """ 53 | if hasattr(handler, "allowed_groups"): 54 | for group in handler.allowed_groups: 55 | if clientinfo.is_in_servergroups(group): 56 | return True 57 | else: 58 | for group in self.accept_from_groups: 59 | if clientinfo.is_in_servergroups(group): 60 | return True 61 | return False 62 | 63 | def handle_command(self, msg, sender=0): 64 | """ 65 | Handle a new command by informing the corresponding handlers. 66 | :param msg: Command message. 67 | :param sender: Client id of the sender. 68 | """ 69 | logger.debug("Handling message " + msg) 70 | command = msg.split(None, 1)[0] 71 | if len(command) > 1: 72 | command = command[1:] 73 | handlers = self.handlers.get(command) 74 | ci = ClientInfo.ClientInfo(sender, self.ts3conn) 75 | handled = False 76 | if handlers is not None: 77 | for handler in handlers: 78 | if self.check_permission(handler, ci): 79 | handled = True 80 | handler(sender, msg) 81 | if not handled: 82 | Bot.send_msg_to_client(self.ts3conn, sender, "You are not allowed to use this command!") 83 | else: 84 | Bot.send_msg_to_client(self.ts3conn, sender, "I cannot interpret your command. I am very sorry. :(") 85 | logger.info("Unknown command " + msg + " received!") 86 | 87 | def inform(self, event): 88 | """ 89 | Inform the EventHandler of a new event. 90 | :param event: New event. 91 | """ 92 | if type(event) is Events.TextMessageEvent: 93 | if event.targetmode == "Private": 94 | if event.invoker_id != int(self.ts3conn.whoami()["client_id"]): # Don't talk to yourself ... 95 | ci = ClientInfo.ClientInfo(event.invoker_id, self.ts3conn) 96 | self.logger.info("Message: " + event.message + " from: " + ci.name) 97 | self.handle_command(event.message, sender=event.invoker_id) 98 | -------------------------------------------------------------------------------- /EventHandler.py: -------------------------------------------------------------------------------- 1 | """EventHandler for the Teamspeak3 Bot.""" 2 | import logging 3 | import threading 4 | 5 | import ts3API.Events as Events 6 | 7 | 8 | class EventHandler(object): 9 | """ 10 | EventHandler class responsible for delegating events to registered listeners. 11 | """ 12 | logger = logging.getLogger("eventhandler") 13 | logger.setLevel(logging.INFO) 14 | file_handler = logging.FileHandler("eventhandler.log", mode='a+') 15 | formatter = logging.Formatter('Eventhandler Logger %(asctime)s %(message)s') 16 | file_handler.setFormatter(formatter) 17 | logger.addHandler(file_handler) 18 | logger.info("Configured Eventhandler logger") 19 | logger.propagate = 0 20 | 21 | def __init__(self, ts3conn, command_handler): 22 | self.ts3conn = ts3conn 23 | self.command_handler = command_handler 24 | self.observers = {} 25 | self.add_observer(self.command_handler.inform, Events.TextMessageEvent) 26 | 27 | def on_event(self, _sender, **kw): 28 | """ 29 | Called upon a new event. Logs the event and informs all listeners. 30 | """ 31 | # parsed_event = Events.EventParser.parse_event(event=event) 32 | parsed_event = kw["event"] 33 | if type(parsed_event) is Events.TextMessageEvent: 34 | logging.debug(type(parsed_event)) 35 | elif type(parsed_event) is Events.ChannelEditedEvent: 36 | logging.debug(type(parsed_event)) 37 | elif type(parsed_event) is Events.ChannelDescriptionEditedEvent: 38 | logging.debug(type(parsed_event)) 39 | elif type(parsed_event) is Events.ClientEnteredEvent: 40 | logging.debug(type(parsed_event)) 41 | elif isinstance(parsed_event, Events.ClientLeftEvent): 42 | logging.debug(type(parsed_event)) 43 | elif type(parsed_event) is Events.ClientMovedEvent: 44 | logging.debug(type(parsed_event)) 45 | elif type(parsed_event) is Events.ClientMovedSelfEvent: 46 | logging.debug(type(parsed_event)) 47 | elif type(parsed_event) is Events.ServerEditedEvent: 48 | logging.debug("Event of type " + str(type(parsed_event))) 49 | logging.debug(parsed_event.changed_properties) 50 | # Inform all observers 51 | self.inform_all(parsed_event) 52 | 53 | def get_obs_for_event(self, evt): 54 | """ 55 | Get all observers for an event. 56 | :param evt: Event to get observers for. 57 | :return: List of observers. 58 | :rtype: list[function] 59 | """ 60 | obs = set() 61 | for t in type(evt).mro(): 62 | obs.update(self.observers.get(t, set())) 63 | return obs 64 | 65 | def add_observer(self, obs, evt_type): 66 | """ 67 | Add an observer for an event type. 68 | :param obs: Function to call upon a new event of type evt_type. 69 | :param evt_type: Event type to observe. 70 | :type evt_type: TS3Event 71 | """ 72 | obs_set = self.observers.get(evt_type, set()) 73 | obs_set.add(obs) 74 | self.observers[evt_type] = obs_set 75 | 76 | def remove_observer(self, obs, evt_type): 77 | """ 78 | Remove an observer for an event type. 79 | :param obs: Observer to remove. 80 | :param evt_type: Event type to remove the observer from. 81 | """ 82 | self.observers.get(evt_type, set()).discard(obs) 83 | 84 | def remove_observer_from_all(self, obs): 85 | """ 86 | Removes an observer from all event_types. 87 | :param obs: Observer to remove. 88 | """ 89 | for evt_type in self.observers.keys(): 90 | self.remove_observer(obs, evt_type) 91 | 92 | # We really want to catch all exception here, to prevent one observer from crashing the bot 93 | # noinspection PyBroadException 94 | def inform_all(self, evt): 95 | """ 96 | Inform all observers registered to the event type of an event. 97 | :param evt: Event to inform observers of. 98 | """ 99 | for o in self.get_obs_for_event(evt): 100 | try: 101 | threading.Thread(target=o(evt)).start() 102 | except BaseException: 103 | EventHandler.logger.exception("Exception while informing %s of Event of type " 104 | "%s\nOriginal data: %s", str(o), str(type(evt)), 105 | str(evt.data)) 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Moduleloader.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | import sys 4 | 5 | from CommandHandler import CommandHandler 6 | from EventHandler import EventHandler 7 | 8 | setups = [] 9 | exits = [] 10 | plugin_modules = {} 11 | event_handler: 'EventHandler' 12 | command_handler: 'CommandHandler' 13 | logger = logging.getLogger("moduleloader") 14 | logger.setLevel(logging.INFO) 15 | file_handler = logging.FileHandler("moduleloader.log", mode='a+') 16 | formatter = logging.Formatter('Moduleloader Logger %(asctime)s %(message)s') 17 | file_handler.setFormatter(formatter) 18 | logger.addHandler(file_handler) 19 | logger.info("Configured Moduleloader logger") 20 | logger.propagate = 0 21 | 22 | 23 | # We really really want to catch all Exception here to prevent a bad module crashing the 24 | # whole Bot 25 | # noinspection PyBroadException,PyPep8 26 | def load_modules(bot, config): 27 | """ 28 | Load modules specified in the Plugins section of config.ini. 29 | :param bot: Bot to pass to the setup function of the modules 30 | :param config: Main bot config with plugins section 31 | """ 32 | global event_handler, command_handler 33 | plugins = config.pop('Plugins') 34 | event_handler = bot.event_handler 35 | command_handler = bot.command_handler 36 | """try: 37 | modules = map(__import__, plugins.values()) 38 | print(modules) 39 | except: 40 | logger.exception("error on importing plugins")""" 41 | 42 | for plugin in plugins.items(): 43 | try: 44 | plugin_modules[plugin[0]] = importlib.import_module("modules."+plugin[1], 45 | package="modules") 46 | plugin_modules[plugin[0]].pluginname = plugin[0] 47 | logger.info("Loaded module " + plugin[0]) 48 | except BaseException: 49 | logger.exception("While loading plugin " + str(plugin[0]) + " from modules."+plugin[1]) 50 | # Call all registered setup functions 51 | for setup_func in setups: 52 | try: 53 | name = sys.modules.get(setup_func.__module__).pluginname 54 | if name in config: 55 | plugin_config = config.pop(name) 56 | setup_func(ts3bot=bot, **plugin_config) 57 | else: 58 | setup_func(bot) 59 | except BaseException: 60 | logger.exception("While setting up a module.") 61 | 62 | 63 | def setup(function): 64 | """ 65 | Decorator for registering the setup function of a module. 66 | :param function: Function to register as setup 67 | :return: 68 | """ 69 | setups.append(function) 70 | return function 71 | 72 | 73 | def event(*event_types): 74 | """ 75 | Decorator to register a function as an eventlistener for the event types specified in 76 | event_types. 77 | :param event_types: Event types to listen to 78 | :type event_types: TS3Event 79 | """ 80 | def register_observer(function): 81 | for event_type in event_types: 82 | event_handler.add_observer(function, event_type) 83 | return function 84 | return register_observer 85 | 86 | 87 | def command(*command_list): 88 | """ 89 | Decorator to register a function as a handler for text commands. 90 | :param command_list: Commands to handle. 91 | :type command_list: str 92 | :return: 93 | """ 94 | def register_command(function): 95 | for text_command in command_list: 96 | command_handler.add_handler(function, text_command) 97 | return function 98 | return register_command 99 | 100 | 101 | def group(*groups): 102 | """ 103 | Decorator to specify which groups are allowed to use the commands specified for this function. 104 | :param groups: List of server groups that are allowed to use the commands associated with this 105 | function. 106 | :type groups: str 107 | """ 108 | def save_allowed_groups(func): 109 | func.allowed_groups = groups 110 | return func 111 | return save_allowed_groups 112 | 113 | 114 | def exit(function): 115 | """ 116 | Decorator to mark a function to be called upon module exit. 117 | :param function: Exit function to call. 118 | """ 119 | exits.append(function) 120 | 121 | 122 | # We really really want to catch all Exception here to prevent a bad module preventing everything 123 | # else from exiting 124 | # noinspection PyBroadException 125 | def exit_all(): 126 | """ 127 | Exit all modules by calling their exit function. 128 | """ 129 | for exit_func in exits: 130 | try: 131 | exit_func() 132 | except BaseException: 133 | logger.exception("While exiting a module.") 134 | 135 | 136 | """def reload(): 137 | exit_all()""" 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TS3 Bot 2 | Simple Teamspeak 3 bot based on the ts3API located at 3 | https://github.com/Murgeye/ts3API. 4 | 5 | # Table of Contents 6 | - [Getting the bot](#getting-the-bot) 7 | - [Configuration](#configuration) 8 | - [Running the bot](#running-the-bot) 9 | - [Permissions](#permissions) 10 | - [Use SSH](#using-ssh) 11 | - [Standard Plugins](#standard-plugins) 12 | - [Utils](#utils) 13 | - [AfkMover](#afkmover) 14 | - [Quotes](#quotes) 15 | - [Standard commands](#standard-commands) 16 | - [Utils](#utils-1) 17 | - [AfkMover](#afkmover-1) 18 | - [Quotes](#quotes-1) 19 | - [Writing plugins](#writing-plugins) 20 | - [Adding setup and exit methods](#adding-setup-and-exit-methods) 21 | - [Adding a text command](#adding-a-text-command) 22 | - [@group](#group) 23 | - [Listening for events](#listening-for-events) 24 | - [Troubleshooting](#troubleshooting) 25 | 26 | # Getting the bot 27 | 1. Clone this repository 28 | 2. Update the ts3API submodule by running `git submodule update --init --recursive` in the directory created in 1. 29 | 3. Install dependencies for the api by running `pip3 install -r ts3/requirements.txt`. 30 | 4. (Optional) Install optional dependencies for ssh by running `pip3 install -r ts3/optional_requirements.txt`. 31 | 32 | # Configuration 33 | You need a configuration file called config.ini in the bots root directory. 34 | config.example.ini should help to get you started with that. The format of the 35 | config file is as follows: 36 | 37 | ``` 38 | [General] 39 | #Nickname of the bot 40 | Botname: ExampleBot 41 | #IP or dns name of the server 42 | Host: 127.0.0.1 43 | #Server query port 44 | Port: 10011 45 | #Virtual Server id, usually 1 if you are running only one server 46 | ServerId: 1 47 | #Channel to move the bot to on joining the server 48 | DefaultChannel: Botchannel 49 | #Server Query Login Name 50 | User: serveradmin 51 | #ServerQueryPassword 52 | Password: password 53 | # Use SSH connection 54 | SSH: False 55 | # Accept all SSH host keys (do not leave this enabled) 56 | AcceptAllSSHKeys = True 57 | # File to load and save host keys to 58 | SSHHostKeyFile = ssh_hostkeys 59 | # Load system wide host keys 60 | SSHLoadSystemHostKeys = False 61 | 62 | #Configuration for Plugins, each line corresponds to 63 | #a plugin in the modules folder 64 | [Plugin] 65 | #Format is: ModuleName, python module name 66 | #You can use module paths by using points: 67 | #e.g.: complex_module.example_module => module/complex_module/example_module 68 | #The module name is only used for logging, it can be anything, but not empty 69 | AfkMover: afkmover 70 | UtilCommand: utils 71 | ``` 72 | 73 | # Running the bot 74 | You can run the bot by executing main.py. If you intend to run the bot on boot 75 | you should probably create a bash script that sleeps before starting the bot to 76 | allow the Teamspeak server to startup first: 77 | ``` 78 | #!/bin/bash 79 | cd /path/to/bot 80 | sleep 60 81 | ./main.py &> output.log 82 | ``` 83 | 84 | ## Permissions 85 | If you want your users to be able to see and message the bot, you will have to change 86 | some server permission -- either for specific clients or for server groups. These are 87 | the `i_client_serverquery_view_power` (set it to 100 if you want a client/group to be 88 | able to see the bot) and the `i_client_private_textmessage_power` (set it to 100 if you 89 | want a client/group to be able to write textmessages (and therefore commands) to the bot). 90 | 91 | Alternatively, you could modify the needed power for both permissions for the serverquery. 92 | 93 | To see these permission settings you have to enable advanced permissions under 94 | `Tools->Options` in your client. 95 | 96 | ![Show Advanced Permissions](advanced_permissions.png) 97 | 98 | ## Using SSH 99 | Since server version 3.3 TeamSpeak supports encrypted server query clients. To achieve this 100 | the connection is wrapped inside a SSH connection. As SSH needs a way to check the host RSA 101 | key, four config options were added: 102 | * SSH: `[y/n/True/False/0/1]` Enables SSH connections (do not forget to enable it on the server 103 | and change the port as well) 104 | * AcceptAllSSHKeys: `[y/n/True/False/0/1]` Accept all RSA host keys 105 | * SSHHostKeyFile: File to save/load host keys to/from 106 | * SSHLoadSystemHostKeys: `[y/n/True/False/0/1]` Load system wide host keys 107 | 108 | To add your host key the following workflow is easiest: 109 | 1. Activate AcceptAllHostKeys and set a SSHHostKeyFile 110 | 2. Connect to the server 111 | 3. The servers host key is automatically added to the file 112 | 4. Deactivate AcceptAllHostKeys 113 | 114 | # Standard Plugins 115 | All existing functionality is based on plugins. 116 | ## Utils 117 | A small plugin with some convenience commands for administration and fun. 118 | ## AfkMover 119 | Moves people from and to a specific channel upon marking themselves as AFK/back. To change the 120 | channel to move people to change `channel_name`at the top of the `modules/afkmover.py` file. 121 | ## Quotes 122 | A module to save quotes and send them to people joining the server. You can add famous quotes 123 | or (for lots of fun) quotes from people on your server. See standard commands for info on how to 124 | do that. 125 | 126 | # Standard commands 127 | All of the textcommands are added via plugins (see the next section). Current plugins include: 128 | ## Utils 129 | A small plugin with some convenience commands for administration and fun. 130 | * !hello - Answers with a message depending on the server group(Server Admin, Moderator, Normal) 131 | * !stop - Stop the bot 132 | * !restart - Restart the bot 133 | * !multimove channel1 channel2 - Move all users from channel 1 to channel 2 (should work for channels containing spaces, most of the time) 134 | * !kickme - Kick yourself from the server. 135 | * !whoami - Fun command. 136 | * !version - Answer with the current module version 137 | 138 | ## AfkMover 139 | * !startafk/!afkstart/!afkmove - Start the Afk Mover 140 | * !stopafk/!afkstop Stop the AFK Mover 141 | 142 | ## Quotes 143 | * !addquote quote - Add a new quote 144 | 145 | # Writing plugins 146 | A feature of this bot that it is easily extendable. You can see some example plugins 147 | in the directory modules. To write your own plugin you need to do the following things: 148 | 149 | 1. Create a python module in the modules folder (or a subfolder) 150 | 2. Add the plugin to the config.ini 151 | 152 | That's it. The plugin doesn't do anything yet, but we can build up on that. 153 | 154 | ## Adding setup and exit methods 155 | Upon loading a plugin the ModuleLoader calls any method marked as `@setup` in the plugin. 156 | ``` 157 | from Moduleloader import * 158 | 159 | @setup 160 | def setup_module(ts3bot): 161 | #Do something, save the bot reference, etc 162 | pass 163 | ``` 164 | 165 | Upon unloading a module (usually if the bot is closed etc) the ModuleLoader calls any method 166 | marked as `@exit` in the plugin. 167 | ``` 168 | @exit 169 | def exit_module(): 170 | #Do something, save your state, etc 171 | pass 172 | ``` 173 | 174 | ## Adding a text command 175 | You can register your plugin to specific commands (starting with !) send via private message 176 | by using the `@command` decorator. 177 | ``` 178 | @command('test1','test2',) 179 | @group('Server Admin',) 180 | def test_command(sender, msg): 181 | print("test") 182 | ``` 183 | This registers the test_command function for the command !test1 and !test2. You can register a 184 | function for as many commands as you want and you can register as many functions for a command as you want. 185 | 186 | The `sender` argument is the client id of the user who sent the command, `msg` contains the whole text 187 | of the private message. 188 | ### `@group` 189 | The `@group` decorator specifies which Server Groups are allowed to use this function via textcommands. You can 190 | use regex here so you can do things like `@group('.*Admin.*','Moderator',)` to allow all groups containing the 191 | word Admin and the Moderator group to send this command. `@group('.*')` allows everybody to use a command. If 192 | you don't use `@group` the default will be to allow access to 'Server Admin' and 'Moderator'. 193 | 194 | ## Listening for events 195 | You can register a function in your plugin to listen for specific server events by using the `@event` decorator. 196 | ``` 197 | import ts3API.Events as Events 198 | # ... 199 | @event(Events.ClientEnteredEvent,) 200 | def inform_enter(event): 201 | print("Client with id " + event.client_id + " left.") 202 | ``` 203 | This code snippet registers the `inform_enter` function as a listener for the `Events.ClientEnteredEvent`. You 204 | can register a function for multiple events by passing a list of event types to the decorator. To learn more 205 | about the events look at the ts3API.Events module. 206 | 207 | # Troubleshooting 208 | ## The bot just crashes without any message 209 | Any error messages should be in the file bot.log in the root directory of the bot. 210 | If this file doesn't exist the file permissions of the root directory are probably wrong. 211 | 212 | ## The bot connects but I cannot see it. 213 | First, make sure that you have set up the server permissions correctly as mentioned in the 214 | [Permissions](#permissions) section. In addition to this, you might have to enable the 215 | "Show ServerQuery Clients" setting in your TeamSpeak client under Bookmarks->Manage Bookmarks 216 | and reconnect to the server. You might have to enable advanced settings for this. 217 | 218 | ![Show Serverquery Setting](show_serverquery.png) 219 | 220 | If you still cannot see the bot after reconnecting, check if the bot is really still connected 221 | by checking the logs of both the bot and the server. If you cannot find the problem, feel free 222 | to open a new issue. 223 | 224 | ## The bot does not react to commands. 225 | The bot can only handle commands via direct message. If you are sending a direct message and the bot still 226 | does not react, try setting the permissions as mentioned in the [Permissions](#permissions) section. 227 | 228 | ## The bot always loses connection after some time. 229 | Your `query_timeout`parameter in the `ts3server.ini` file is probably very low (<10 seconds). 230 | Please set it to a higher value or 0 (this disables the query timeout). If this does not fix 231 | it, feel free to open an issue, there might still be some unresolved problems here. 232 | 233 | ## The Bot gets banned from our server! 234 | You need to whitelist the ip the bot is connecting from in the Teamspeak configuration file. To do this 235 | change the file `query-ip-whitelist.txt` in the server directory and add a new line with your ip. 236 | 237 | ## Something doesn't work 238 | The bot writes quite some logs in the root directory. Check those for errors and open 239 | an issue if the issue remains. 240 | 241 | ## The bot stopped working after I updated my server! 242 | Update the bot! Server version 3.4.0 changed the way the query timeout was handled. 243 | Versions of the bot older then 17. September 2018 will not work correctly. 244 | -------------------------------------------------------------------------------- /advanced_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Murgeye/teamspeak3-python-bot/88c9677af26b602b3b9f8734513c2ac3012d8928/advanced_permissions.png -------------------------------------------------------------------------------- /config.example.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | Botname: ExampleBot 3 | Host: 127.0.0.1 4 | Port: 10011 5 | ServerId: 1 6 | DefaultChannel: Botchannel 7 | User: serveradmin 8 | Password: query-password 9 | SSH: False 10 | AcceptAllSSHKeys: True 11 | SSHHostKeyFile: ssh_hostkeys 12 | SSHLoadSystemHostKeys: False 13 | 14 | [Plugins] 15 | Quotes: Quotes 16 | AfkMover: afkmover 17 | UtilCommand: utils 18 | -------------------------------------------------------------------------------- /event_documentation: -------------------------------------------------------------------------------- 1 | notifytextmessage: private message, channel message, server message 2 | notifyclientmoved: leave channel, join channel, move user, kick from channel 3 | notifyclientleftview: leave server, kick from server 4 | notifycliententerview: join server 5 | notifychanneledited: Channel edited (except channel description) 6 | notifychanneldescriptionchanged: channel description edited 7 | notifyserveredited: Server edited 8 | 9 | targetmodes: 10 | 1: private message 11 | 2: channel message 12 | 3: server message 13 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import logging 3 | import os 4 | import sys 5 | import threading 6 | 7 | from ts3API.utilities import TS3ConnectionClosedException 8 | 9 | import Bot 10 | 11 | logger = None 12 | bot = None 13 | 14 | 15 | def exception_handler(exctype, value, tb): 16 | """ 17 | Exception handler to prevent any exceptions from printing to stdout. Logs all exceptions to the logger. 18 | :param exctype: Exception type 19 | :param value: Exception value 20 | :param tb: Exception traceback 21 | :return: 22 | """ 23 | logger.error("Uncaught exception.", exc_info=(exctype, value, tb)) 24 | 25 | 26 | def restart_program(): 27 | """ 28 | Restarts the current program. 29 | Note: this function does not return. Any cleanup action (like 30 | saving data) must be done before calling this function. 31 | """ 32 | python = sys.executable 33 | os.execl(python, python, * sys.argv) 34 | 35 | 36 | def main(): 37 | """ 38 | Start the bot, set up logger and set exception hook. 39 | :return: 40 | """ 41 | run_old = threading.Thread.run 42 | 43 | def run(*args, **kwargs): 44 | try: 45 | run_old(*args, **kwargs) 46 | except (KeyboardInterrupt, SystemExit, TS3ConnectionClosedException): 47 | # This is a very ungraceful exit! 48 | os._exit(-1) 49 | raise 50 | except: 51 | sys.excepthook(*sys.exc_info()) 52 | threading.Thread.run = run 53 | global bot, logger 54 | logger = logging.getLogger("bot") 55 | if not logger.hasHandlers(): 56 | logger.propagate = 0 57 | logger.setLevel(logging.INFO) 58 | file_handler = logging.FileHandler("bot.log", mode='a+') 59 | formatter = logging.Formatter("%(asctime)s: %(levelname)s: %(message)s") 60 | file_handler.setFormatter(formatter) 61 | logger.addHandler(file_handler) 62 | logger.info('Started') 63 | sys.excepthook = exception_handler 64 | config = Bot.Ts3Bot.parse_config(logger) 65 | bot = Bot.Ts3Bot.bot_from_config(config) 66 | 67 | if __name__ == "__main__": 68 | main() 69 | -------------------------------------------------------------------------------- /modules/Quotes.py: -------------------------------------------------------------------------------- 1 | """Quote module for the Teamspeak 3 Bot. Sends quotes to people joining the server.""" 2 | import codecs 3 | import random 4 | 5 | import ts3API.Events as Events 6 | 7 | import Bot 8 | import Moduleloader 9 | 10 | bot: Bot.Ts3Bot 11 | # Server groups who should not receiver quotes upon joining the server 12 | dont_send = [] 13 | 14 | 15 | def random_line(afile): 16 | """ 17 | Get a random line from a file. 18 | :param afile: File to read from. 19 | :return: Random line 20 | """ 21 | line = next(afile) 22 | for num, aline in enumerate(afile): 23 | if random.randrange(num + 2): 24 | continue 25 | line = aline 26 | return line 27 | 28 | 29 | def add(q): 30 | """ 31 | Add a new quote. 32 | :param q: Quote to add. 33 | """ 34 | with codecs.open("quotes", "a+", "ISO-8859-1") as f: 35 | f.write(q+"\n") 36 | 37 | 38 | @Moduleloader.setup 39 | def setup_quoter(ts3bot): 40 | """ 41 | Setup the quoter. Define groups not to send quotes to. 42 | :return: 43 | """ 44 | global bot, dont_send 45 | bot = ts3bot 46 | ts3conn = bot.ts3conn 47 | for g in ts3conn.servergrouplist(): 48 | if g.get('name', '') in ["Guest", "Admin Server Query"]: 49 | dont_send.append(int(g.get('sgid', 0))) 50 | 51 | 52 | @Moduleloader.event(Events.ClientEnteredEvent,) 53 | def inform(evt): 54 | """ 55 | Send out a quote to joining users. 56 | :param evt: ClientEnteredEvent 57 | """ 58 | for g in evt.client_servergroups.split(','): 59 | if len(g) == 0 or int(g) in dont_send: 60 | return 61 | with codecs.open("quotes", "r", "ISO-8859-1") as f: 62 | quote = "" 63 | while len(quote) == 0: 64 | quote = random_line(f) 65 | Bot.send_msg_to_client(bot.ts3conn, evt.client_id, quote) 66 | 67 | 68 | @Moduleloader.command('addquote',) 69 | def add_quote(sender, msg): 70 | """ 71 | Add a quote. 72 | """ 73 | if len(msg) > len("!addQuote "): 74 | add(msg[len("!addQuote "):]) 75 | Bot.send_msg_to_client(bot.ts3conn, sender, "Quote '" + msg[len("!addQuote "):] + 76 | "' was added.") 77 | 78 | -------------------------------------------------------------------------------- /modules/afkmover.py: -------------------------------------------------------------------------------- 1 | """AfkMover Module for the Teamspeak3 Bot.""" 2 | import threading 3 | import traceback 4 | from threading import Thread 5 | 6 | import ts3API.Events as Events 7 | from ts3API.utilities import TS3Exception 8 | 9 | from Moduleloader import * 10 | import Bot 11 | from typing import Union 12 | 13 | afkMover: Union[None, 'AfkMover'] = None 14 | afkStopper = threading.Event() 15 | bot: Bot.Ts3Bot 16 | autoStart = True 17 | channel_name = "AFK" 18 | 19 | 20 | class AfkMover(Thread): 21 | """ 22 | AfkMover class. Moves clients set to afk another channel. 23 | """ 24 | logger = logging.getLogger("afk") 25 | logger.propagate = 0 26 | logger.setLevel(logging.WARNING) 27 | file_handler = logging.FileHandler("afk.log", mode='a+') 28 | formatter = logging.Formatter('AFK Logger %(asctime)s %(message)s') 29 | file_handler.setFormatter(formatter) 30 | logger.addHandler(file_handler) 31 | logger.info("Configured afk logger") 32 | logger.propagate = 0 33 | 34 | def __init__(self, stop_event, ts3conn): 35 | """ 36 | Create a new AfkMover object. 37 | :param stop_event: Event to signalize the AfkMover to stop moving. 38 | :type stop_event: threading.Event 39 | :param ts3conn: Connection to use 40 | :type: TS3Connection 41 | """ 42 | Thread.__init__(self) 43 | self.stopped = stop_event 44 | self.ts3conn = ts3conn 45 | self.afk_channel = self.get_afk_channel(channel_name) 46 | self.client_channels = {} 47 | self.afk_list = None 48 | if self.afk_channel is None: 49 | AfkMover.logger.error("Could not get afk channel") 50 | 51 | def run(self): 52 | """ 53 | Thread run method. Starts the mover. 54 | """ 55 | AfkMover.logger.info("AFKMove Thread started") 56 | try: 57 | self.auto_move_all() 58 | except BaseException: 59 | self.logger.exception("Exception occured in run:") 60 | 61 | def update_afk_list(self): 62 | """ 63 | Update the list of clients. 64 | """ 65 | try: 66 | self.afk_list = self.ts3conn.clientlist(["away"]) 67 | AfkMover.logger.debug("Awaylist: " + str(self.afk_list)) 68 | except TS3Exception: 69 | AfkMover.logger.exception("Error getting away list!") 70 | self.afk_list = list() 71 | 72 | def get_away_list(self): 73 | """ 74 | Get list of clients with afk status. 75 | :return: List of clients that are set to afk. 76 | """ 77 | if self.afk_list is not None: 78 | AfkMover.logger.debug(str(self.afk_list)) 79 | awaylist = list() 80 | for client in self.afk_list: 81 | AfkMover.logger.debug(str(self.afk_list)) 82 | if "cid" not in client.keys(): 83 | AfkMover.logger.error("Client without cid!") 84 | AfkMover.logger.error(str(client)) 85 | elif "client_away" in client.keys() and client.get("client_away", '0') == '1' \ 86 | and int(client.get("cid", '-1')) != int(self.afk_channel): 87 | awaylist.append(client) 88 | return awaylist 89 | else: 90 | AfkMover.logger.error("Clientlist is None!") 91 | return list() 92 | 93 | def get_back_list(self): 94 | """ 95 | Get list of clients in the afk channel, but not away. 96 | :return: List of clients who are back from afk. 97 | """ 98 | clientlist = [client for client in self.afk_list if client.get("client_away", '1') == '0' 99 | and int(client.get("cid", '-1')) == int(self.afk_channel)] 100 | return clientlist 101 | 102 | def get_afk_channel(self, name="AFK"): 103 | """ 104 | Get the channel id of the channel specified by name. 105 | :param name: Channel name 106 | :return: Channel id 107 | """ 108 | try: 109 | channel = self.ts3conn.channelfind(name)[0].get("cid", '-1') 110 | except TS3Exception: 111 | AfkMover.logger.exception("Error getting afk channel") 112 | raise 113 | return channel 114 | 115 | def move_to_afk(self, clients): 116 | """ 117 | Move clients to the afk_channel. 118 | :param clients: List of clients to move. 119 | """ 120 | AfkMover.logger.info("Moving clients to afk!") 121 | for client in clients: 122 | AfkMover.logger.info("Moving somebody to afk!") 123 | AfkMover.logger.debug("Client: " + str(client)) 124 | try: 125 | self.ts3conn.clientmove(self.afk_channel, int(client.get("clid", '-1'))) 126 | except TS3Exception: 127 | AfkMover.logger.exception("Error moving client! Clid=" + 128 | str(client.get("clid", '-1'))) 129 | self.client_channels[client.get("clid", '-1')] = client.get("cid", '0') 130 | AfkMover.logger.debug("Moved List after move: " + str(self.client_channels)) 131 | 132 | def move_all_afk(self): 133 | """ 134 | Move all afk clients. 135 | """ 136 | try: 137 | afk_list = self.get_away_list() 138 | self.move_to_afk(afk_list) 139 | except AttributeError: 140 | AfkMover.logger.exception("Connection error!") 141 | 142 | def move_all_back(self): 143 | """ 144 | Move all clients who are back from afk. 145 | """ 146 | back_list = self.get_back_list() 147 | AfkMover.logger.debug("Moving clients back") 148 | AfkMover.logger.debug("Backlist is: %s", str(back_list)) 149 | AfkMover.logger.debug("Saved channel list keys are: %s\n", str(self.client_channels.keys())) 150 | for client in back_list: 151 | if client.get("clid", -1) in self.client_channels.keys(): 152 | AfkMover.logger.info("Moving a client back!") 153 | AfkMover.logger.debug("Client: " + str(client)) 154 | AfkMover.logger.debug("Saved channel list keys:" + str(self.client_channels)) 155 | self.ts3conn.clientmove(self.client_channels.get(client.get("clid", -1)), 156 | int(client.get("clid", '-1'))) 157 | del self.client_channels[client.get("clid", '-1')] 158 | 159 | def auto_move_all(self): 160 | """ 161 | Loop move functions until the stop signal is sent. 162 | """ 163 | while not self.stopped.wait(2.0): 164 | AfkMover.logger.debug("Afkmover running!") 165 | self.update_afk_list() 166 | try: 167 | self.move_all_back() 168 | self.move_all_afk() 169 | except BaseException: 170 | AfkMover.logger.error("Uncaught exception:" + str(sys.exc_info()[0])) 171 | AfkMover.logger.error(str(sys.exc_info()[1])) 172 | AfkMover.logger.error(traceback.format_exc()) 173 | AfkMover.logger.error("Saved channel list keys are: %s\n", 174 | str(self.client_channels.keys())) 175 | AfkMover.logger.warning("AFKMover stopped!") 176 | self.client_channels = {} 177 | 178 | 179 | @command('startafk', 'afkstart', 'afkmove',) 180 | def start_afkmover(_sender=None, _msg=None): 181 | """ 182 | Start the AfkMover by clearing the afkStopper signal and starting the mover. 183 | """ 184 | global afkMover 185 | if afkMover is None: 186 | afkMover = AfkMover(afkStopper, bot.ts3conn) 187 | afkStopper.clear() 188 | afkMover.start() 189 | 190 | 191 | @command('stopafk', 'afkstop') 192 | def stop_afkmover(_sender=None, _msg=None): 193 | """ 194 | Stop the AfkMover by setting the afkStopper signal and undefining the mover. 195 | """ 196 | global afkMover 197 | afkStopper.set() 198 | afkMover = None 199 | 200 | 201 | @command('afkgetclientchannellist') 202 | def get_afk_list(sender=None, _msg=None): 203 | """ 204 | Get afkmover saved client channels. Mainly for debugging. 205 | """ 206 | if afkMover is not None: 207 | Bot.send_msg_to_client(bot.ts3conn, sender, str(afkMover.client_channels)) 208 | 209 | 210 | @event(Events.ClientLeftEvent,) 211 | def client_left(event_data): 212 | """ 213 | Clean up leaving clients. 214 | """ 215 | # Forget clients that were set to afk and then left 216 | if afkMover is not None: 217 | if str(event_data.client_id) in afkMover.client_channels: 218 | del afkMover.client_channels[str(event_data.client_id)] 219 | 220 | 221 | @setup 222 | def setup(ts3bot, channel=channel_name): 223 | global bot, channel_name 224 | bot = ts3bot 225 | channel_name = channel 226 | if autoStart: 227 | start_afkmover() 228 | 229 | 230 | @exit 231 | def afkmover_exit(): 232 | global afkMover 233 | if afkMover is not None: 234 | afkStopper.set() 235 | afkMover.join() 236 | afkMover = None 237 | 238 | 239 | -------------------------------------------------------------------------------- /modules/phrasendrescher.py: -------------------------------------------------------------------------------- 1 | """Quote module for the Teamspeak 3 Bot. Sends quotes to people joining the server.""" 2 | import os 3 | import sqlite3 4 | 5 | import ts3API.Events as Events 6 | 7 | import Bot 8 | import Moduleloader 9 | 10 | bot: Bot.Ts3Bot 11 | path: str 12 | # Server groups who should not receive quotes upon joining the server 13 | dont_send = [] 14 | 15 | 16 | @Moduleloader.setup 17 | def setup_quoter(ts3bot, db): 18 | """ 19 | Setup the quoter. Define groups not to send quotes to. 20 | :return: 21 | """ 22 | global bot, dont_send, path 23 | bot = ts3bot 24 | ts3conn = bot.ts3conn 25 | for g in ts3conn.servergrouplist(): 26 | if g.get('name', '') in ["Guest", "Admin Server Query"]: 27 | dont_send.append(int(g.get('sgid', 0))) 28 | if not os.path.isabs(db): 29 | path = os.path.dirname(__file__) 30 | path = os.path.join(path, db) 31 | else: 32 | path = db 33 | path = os.path.abspath(path) 34 | # setup and connect to database 35 | conn = sqlite3.connect(path) 36 | curs = conn.cursor() 37 | curs.execute('CREATE TABLE IF NOT EXISTS Quotes (id integer primary key, quote text,' 38 | 'submitter text, time text, shown integer)') 39 | curs.execute('CREATE UNIQUE INDEX IF NOT EXISTS Quotesididx ON Quotes (id)') 40 | conn.commit() 41 | conn.close() 42 | 43 | 44 | @Moduleloader.command('quote',) 45 | def add_quote(sender, msg): 46 | if len(msg) <= len('!quote '): 47 | Bot.send_msg_to_client(bot.ts3conn, sender, 'Please include a quote to save.') 48 | else: 49 | conn = sqlite3.connect(path) 50 | c = conn.cursor() 51 | quote = msg[len('!quote')+1:] 52 | quote = quote.replace('" ', '"\n') 53 | submitter = bot.ts3conn.clientinfo(sender) 54 | submitter = submitter['client_nickname'] 55 | c.execute('INSERT INTO Quotes (quote, submitter, time, shown) VALUES (?, ?,' 56 | 'strftime("%s", "now"), ?)', (quote, submitter, 0)) 57 | conn.commit() 58 | conn.close() 59 | Bot.send_msg_to_client(bot.ts3conn, sender, 'Your quote has been saved!') 60 | 61 | 62 | @Moduleloader.event(Events.ClientEnteredEvent,) 63 | def send_quote(evt): 64 | for g in evt.client_servergroups.split(','): 65 | if len(g) == 0 or int(g) in dont_send: 66 | return 67 | conn = sqlite3.connect(path) 68 | c = conn.cursor() 69 | c.execute('SELECT * FROM Quotes ORDER BY RANDOM() LIMIT 1') 70 | quote = c.fetchone() 71 | Bot.send_msg_to_client(bot.ts3conn, evt.client_id, quote[1]) 72 | c.execute('UPDATE Quotes SET shown=? WHERE id=?', (quote[4] + 1, quote[0])) 73 | conn.commit() 74 | conn.close() 75 | 76 | -------------------------------------------------------------------------------- /modules/utils.py: -------------------------------------------------------------------------------- 1 | from ts3API.TS3Connection import TS3QueryException 2 | 3 | import Bot 4 | import Moduleloader 5 | from Moduleloader import * 6 | 7 | __version__ = "0.4" 8 | bot: Bot.Ts3Bot 9 | logger = logging.getLogger("bot") 10 | 11 | 12 | @Moduleloader.setup 13 | def setup(ts3bot): 14 | global bot 15 | bot = ts3bot 16 | 17 | 18 | @command('hello', ) 19 | @group('Server Admin', ) 20 | def hello(sender, _msg): 21 | Bot.send_msg_to_client(bot.ts3conn, sender, "Hello Admin!") 22 | 23 | 24 | @command('hello', ) 25 | @group('Moderator', ) 26 | def hello(sender, _msg): 27 | Bot.send_msg_to_client(bot.ts3conn, sender, "Hello Moderator!") 28 | 29 | 30 | @command('hello', ) 31 | @group('Normal', ) 32 | def hello(sender, _msg): 33 | Bot.send_msg_to_client(bot.ts3conn, sender, "Hello Casual!") 34 | 35 | 36 | @command('kickme', 'fuckme') 37 | @group('.*', ) 38 | def kickme(sender, _msg): 39 | ts3conn = bot.ts3conn 40 | ts3conn.clientkick(sender, 5, "Whatever.") 41 | 42 | 43 | @command('mtest', ) 44 | def mtest(_sender, msg): 45 | print("MTES") 46 | channels = msg[len("!mtest "):].split() 47 | print(channels) 48 | ts3conn = bot.ts3conn 49 | print(ts3conn.channelfind(channels[0])) 50 | 51 | 52 | @command('multimove', 'mm') 53 | @group('Server Admin', 'Moderator') 54 | def multi_move(sender, msg): 55 | """ 56 | Move all clients from one channel to another. 57 | :param sender: Client id of sender that sent the command. 58 | :param msg: Sent command. 59 | """ 60 | channels = msg.split()[1:] 61 | source_name = "" 62 | dest_name = "" 63 | source = None 64 | dest = None 65 | ts3conn = bot.ts3conn 66 | if len(channels) < 2: 67 | if sender != 0: 68 | Bot.send_msg_to_client(ts3conn, sender, "Usage: multimove source destination") 69 | return 70 | elif len(channels) > 2: 71 | channel_name_list = ts3conn.channel_name_list() 72 | for channel_name in channel_name_list: 73 | if msg[len("!multimove "):].startswith(channel_name): 74 | source_name = channel_name 75 | dest_name = msg[len("!multimove ") + len(source_name) + 1:] 76 | else: 77 | source_name = channels[0] 78 | dest_name = channels[1] 79 | if source_name == "": 80 | Bot.send_msg_to_client(ts3conn, sender, "Source channel not found") 81 | return 82 | if dest_name == "": 83 | Bot.send_msg_to_client(ts3conn, sender, "Destination channel not found") 84 | return 85 | try: 86 | channel_matches = ts3conn.channelfind(source_name) 87 | channel_candidates = [chan for chan in channel_matches if 88 | chan.get("channel_name", '-1').startswith(source_name)] 89 | if len(channel_candidates) == 1: 90 | source = channel_candidates[0].get("cid", '-1') 91 | elif len(channel_candidates) == 0: 92 | Bot.send_msg_to_client(ts3conn, sender, "Source channel could not be found.") 93 | else: 94 | channels = [chan.get('channel_name') for chan in channel_candidates] 95 | Bot.send_msg_to_client(ts3conn, sender, 96 | "Multiple source channels found: " + ", ".join(channels)) 97 | except TS3QueryException: 98 | Bot.send_msg_to_client(ts3conn, sender, "Source channel not found") 99 | try: 100 | channel_matches = ts3conn.channelfind(dest_name) 101 | channel_candidates = [chan for chan in channel_matches if 102 | chan.get("channel_name", '-1').startswith(dest_name)] 103 | if len(channel_candidates) == 1: 104 | dest = channel_candidates[0].get("cid", '-1') 105 | elif len(channel_candidates) == 0: 106 | Bot.send_msg_to_client(ts3conn, sender, "Destination channel could not be found.") 107 | else: 108 | channels = [chan.get('channel_name') for chan in channel_candidates] 109 | Bot.send_msg_to_client(ts3conn, sender, 110 | "Multiple destination channels found: " + ", ".join(channels)) 111 | except TS3QueryException: 112 | Bot.send_msg_to_client(ts3conn, sender, "Destination channel not found") 113 | if source is not None and dest is not None: 114 | try: 115 | client_list = ts3conn.clientlist() 116 | client_list = [client for client in client_list if client.get("cid", '-1') == source] 117 | for client in client_list: 118 | clid = client.get("clid", '-1') 119 | logger.info("Found client in channel: " + client.get("client_nickname", 120 | "") + " id = " + clid) 121 | ts3conn.clientmove(int(dest), int(clid)) 122 | except TS3QueryException as e: 123 | Bot.send_msg_to_client(ts3conn, sender, 124 | "Error moving clients: id = " + str(e.id) + e.message) 125 | 126 | 127 | @command('version', ) 128 | @group('.*') 129 | def send_version(sender, _msg): 130 | Bot.send_msg_to_client(bot.ts3conn, sender, __version__) 131 | 132 | 133 | @command('whoami', ) 134 | @group('.*') 135 | def whoami(sender, _msg): 136 | Bot.send_msg_to_client(bot.ts3conn, sender, "None of your business!") 137 | 138 | 139 | @command('stop', ) 140 | @group('Server Admin', ) 141 | def stop_bot(_sender, _msg): 142 | Moduleloader.exit_all() 143 | bot.ts3conn.quit() 144 | logger.warning("Bot was quit!") 145 | 146 | 147 | @command('restart', ) 148 | @group('Server Admin', 'Moderator', ) 149 | def restart_bot(_sender, _msg): 150 | Moduleloader.exit_all() 151 | bot.ts3conn.quit() 152 | logger.warning("Bot was quit!") 153 | import main 154 | main.restart_program() 155 | 156 | 157 | @command('commandlist', ) 158 | @group('Server Admin', 'Moderator', ) 159 | def get_command_list(sender, _msg): 160 | Bot.send_msg_to_client(bot.ts3conn, sender, str(list(bot.command_handler.handlers.keys()))) 161 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ts3API[ssh] 2 | -------------------------------------------------------------------------------- /show_serverquery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Murgeye/teamspeak3-python-bot/88c9677af26b602b3b9f8734513c2ac3012d8928/show_serverquery.png --------------------------------------------------------------------------------