#!/usr/bin/env python3 from typing import Any, List, Dict, Callable import os import sys import argparse import zipfile import textwrap import requests import urllib.parse import json from requests import Response red = '\033[91m' # type: str green = '\033[92m' # type: str end_format = '\033[0m' # type: str bold = '\033[1m' # type: str bots_dir = '.bots' # type: str def pack(options: argparse.Namespace) -> None: # Basic sanity checks for input. if not options.path: print('tools/deploy: Path to bot folder not specified.') sys.exit(1) if not options.config: print('tools/deploy: Path to zuliprc not specified.') sys.exit(1) if not options.main: print('tools/deploy: No main bot file specified.') sys.exit(1) if not os.path.isfile(options.config): print('pack: Config file not found at path: {}.'.format(options.config)) sys.exit(1) if not os.path.isdir(options.path): print('pack: Bot folder not found at path: {}.'.format(options.path)) sys.exit(1) main_path = os.path.join(options.path, options.main) if not os.path.isfile(main_path): print('pack: Bot main file not found at path: {}.'.format(main_path)) sys.exit(1) # Main logic for packing the bot. if not os.path.exists(bots_dir): os.makedirs(bots_dir) zip_file_path = os.path.join(bots_dir, options.botname + ".zip") zip_file = zipfile.ZipFile(zip_file_path, 'w', zipfile.ZIP_DEFLATED) # Pack the complete bot folder for root, dirs, files in os.walk(options.path): for file in files: file_path = os.path.join(root, file) zip_file.write(file_path, os.path.relpath(file_path, options.path)) # Pack the zuliprc zip_file.write(options.config, 'zuliprc') # Pack the config file for the botfarm. bot_config = textwrap.dedent('''\ [deploy] bot={} zuliprc=zuliprc '''.format(options.main)) zip_file.writestr('config.ini', bot_config) zip_file.close() print('pack: Created zip file at: {}.'.format(zip_file_path)) def check_common_options(options: argparse.Namespace) -> None: if not options.server: print('tools/deploy: URL to Botfarm server not specified.') sys.exit(1) if not options.token: print('tools/deploy: Botfarm deploy token not specified.') sys.exit(1) def handle_common_response_without_data(response: Response, operation: str, success_message: str) -> bool: return handle_common_response( response=response, operation=operation, success_handler=lambda r: print('{}: {}'.format(operation, success_message)) ) def handle_common_response(response: Response, operation: str, success_handler: Callable[[Dict[str, Any]], Any]) -> bool: if response.status_code == requests.codes.ok: response_data = response.json() if response_data['status'] == 'success': success_handler(response_data) return True elif response_data['status'] == 'error': print('{}: {}'.format(operation, response_data['message'])) return False else: print('{}: Unexpected success response format'.format(operation)) return False if response.status_code == requests.codes.unauthorized: print('{}: Authentication error with the server. Aborting.'.format(operation)) else: print('{}: Error {}. Aborting.'.format(operation, response.status_code)) return False def upload(options: argparse.Namespace) -> None: check_common_options(options) file_path = os.path.join(bots_dir, options.botname + '.zip') if not os.path.exists(file_path): print('upload: Could not find bot package at {}.'.format(file_path)) sys.exit(1) files = {'file': open(file_path, 'rb')} headers = {'key': options.token} url = urllib.parse.urljoin(options.server, 'bots/upload') response = requests.post(url, files=files, headers=headers) result = handle_common_response_without_data(response, 'upload', 'Uploaded the bot package to botfarm.') if result is False: sys.exit(1) def clean(options: argparse.Namespace) -> None: file_path = os.path.join(bots_dir, options.botname + '.zip') if os.path.exists(file_path): os.remove(file_path) print('clean: Removed {}.'.format(file_path)) else: print('clean: File \'{}\' not found.'.format(file_path)) def process(options: argparse.Namespace) -> None: check_common_options(options) headers = {'key': options.token} url = urllib.parse.urljoin(options.server, 'bots/process') payload = {'name': options.botname} response = requests.post(url, headers=headers, json=payload) result = handle_common_response_without_data(response, 'process', 'The bot has been processed by the botfarm.') if result is False: sys.exit(1) def start(options: argparse.Namespace) -> None: check_common_options(options) headers = {'key': options.token} url = urllib.parse.urljoin(options.server, 'bots/start') payload = {'name': options.botname} response = requests.post(url, headers=headers, json=payload) result = handle_common_response_without_data(response, 'start', 'The bot has been started by the botfarm.') if result is False: sys.exit(1) def stop(options: argparse.Namespace) -> None: check_common_options(options) headers = {'key': options.token} url = urllib.parse.urljoin(options.server, 'bots/stop') payload = {'name': options.botname} response = requests.post(url, headers=headers, json=payload) result = handle_common_response_without_data(response, 'stop', 'The bot has been stopped by the botfarm.') if result is False: sys.exit(1) def prepare(options: argparse.Namespace) -> None: pack(options) upload(options) clean(options) process(options) def log(options: argparse.Namespace) -> None: check_common_options(options) headers = {'key': options.token} if options.lines: lines = options.lines else: lines = None payload = {'name': options.botname, 'lines': lines} url = urllib.parse.urljoin(options.server, 'bots/logs/' + options.botname) response = requests.get(url, json=payload, headers=headers) result = handle_common_response(response, 'log', lambda r: print(r['logs']['content'])) if result is False: sys.exit(1) def delete(options: argparse.Namespace) -> None: check_common_options(options) headers = {'key': options.token} url = urllib.parse.urljoin(options.server, 'bots/delete') payload = {'name': options.botname} response = requests.post(url, headers=headers, json=payload) result = handle_common_response_without_data(response, 'delete', 'The bot has been removed from the botfarm.') if result is False: sys.exit(1) def list_bots(options: argparse.Namespace) -> None: check_common_options(options) headers = {'key': options.token} if options.format: pretty_print = True else: pretty_print = False url = urllib.parse.urljoin(options.server, 'bots/list') response = requests.get(url, headers=headers) result = handle_common_response(response, 'ls', lambda r: print_bots(r['bots']['list'], pretty_print)) if result is False: sys.exit(1) def print_bots(bots: List[Any], pretty_print: bool) -> None: if pretty_print: print_bots_pretty(bots) else: for bot in bots: print('{}\t{}\t{}\t{}'.format(bot['name'], bot['status'], bot['email'], bot['site'])) def print_bots_pretty(bots: List[Any]) -> None: if len(bots) == 0: print('ls: No bots found on the botfarm') else: print('ls: There are the following bots on the botfarm:') name_col_len, status_col_len, email_col_len, site_col_len = 25, 15, 35, 35 row_format = '{0} {1} {2} {3}' header = row_format.format( 'NAME'.rjust(name_col_len), 'STATUS'.rjust(status_col_len), 'EMAIL'.rjust(email_col_len), 'SITE'.rjust(site_col_len), ) header_bottom = row_format.format( '-' * name_col_len, '-' * status_col_len, '-' * email_col_len, '-' * site_col_len, ) print(header) print(header_bottom) for bot in bots: row = row_format.format( bot['name'].rjust(name_col_len), bot['status'].rjust(status_col_len), bot['email'].rjust(email_col_len), bot['site'].rjust(site_col_len), ) print(row) def main() -> None: usage = """tools/deploy [options] This is tool meant to easily deploy bots to a Zulip Bot Farm. First, get your deploy token from the Botfarm server. We recommend saving your deploy-token as $TOKEN and the bot-farm server as $SERVER. If you want to manually provide the SERVER and TOKEN values, use the --server="https://my-server.com" and --token="my-access-token" flags with each command. To deploy, run: tools/deploy prepare mybot \\ --path=/path/to/bot/directory --config=/path/to/zuliprc --main=main_bot_file.py Now, your bot is ready to start. tools/deploy start mybot To stop the bot, use: tools/deploy stop mybot To get logs of the bot, use: tools/deploy log mybot To delete the bot, use: tools/deploy delete mybot To list user's bots, use: tools/deploy ls """ parser = argparse.ArgumentParser(usage=usage) parser.add_argument('command', help='Command to run.') parser.add_argument('botname', nargs='?', help='Name of bot to operate on.') parser.add_argument('--server', '-s', metavar='SERVERURL', default=os.environ.get('SERVER', ''), help='Url of the Zulip Botfarm server.') parser.add_argument('--token', '-t', default=os.environ.get('TOKEN', ''), help='Deploy Token for the Botfarm.') parser.add_argument('--path', '-p', help='Path to the bot directory.') parser.add_argument('--config', '-c', help='Path to the zuliprc file.') parser.add_argument('--main', '-m', help='Path to the bot\'s main file, relative to the bot\'s directory.') parser.add_argument('--lines', '-l', help='Number of lines in log required.') parser.add_argument('--format', '-f', action='store_true', help='Print user\'s bots in human readable format') options = parser.parse_args() if not options.command: print('tools/deploy: No command specified.') sys.exit(1) if not options.botname and options.command not in ['ls']: print('tools/deploy: No bot name specified. Please specify a name like \'my-custom-bot\'') sys.exit(1) commands = { 'pack': pack, 'upload': upload, 'clean': clean, 'prepare': prepare, 'process': process, 'start': start, 'stop': stop, 'log': log, 'delete': delete, 'ls': list_bots, } if options.command in commands: commands[options.command](options) else: print('tools/deploy: No command \'{}\' found.'.format(options.command)) if __name__ == '__main__': main()