black: Reformat skipping string normalization.

This commit is contained in:
PIG208 2021-05-28 17:03:46 +08:00 committed by Tim Abbott
parent 5580c68ae5
commit fba21bb00d
178 changed files with 6562 additions and 4469 deletions

View file

@ -8,89 +8,97 @@ if MYPY:
whitespace_rules = [ whitespace_rules = [
# This linter should be first since bash_rules depends on it. # This linter should be first since bash_rules depends on it.
{'pattern': r'\s+$', {'pattern': r'\s+$', 'strip': '\n', 'description': 'Fix trailing whitespace'},
'strip': '\n', {'pattern': '\t', 'strip': '\n', 'description': 'Fix tab-based whitespace'},
'description': 'Fix trailing whitespace'},
{'pattern': '\t',
'strip': '\n',
'description': 'Fix tab-based whitespace'},
] # type: List[Rule] ] # type: List[Rule]
markdown_whitespace_rules = list([rule for rule in whitespace_rules if rule['pattern'] != r'\s+$']) + [ markdown_whitespace_rules = list(
[rule for rule in whitespace_rules if rule['pattern'] != r'\s+$']
) + [
# Two spaces trailing a line with other content is okay--it's a markdown line break. # Two spaces trailing a line with other content is okay--it's a markdown line break.
# This rule finds one space trailing a non-space, three or more trailing spaces, and # This rule finds one space trailing a non-space, three or more trailing spaces, and
# spaces on an empty line. # spaces on an empty line.
{'pattern': r'((?<!\s)\s$)|(\s\s\s+$)|(^\s+$)', {
'strip': '\n', 'pattern': r'((?<!\s)\s$)|(\s\s\s+$)|(^\s+$)',
'description': 'Fix trailing whitespace'}, 'strip': '\n',
{'pattern': r'^#+[A-Za-z0-9]', 'description': 'Fix trailing whitespace',
'strip': '\n', },
'description': 'Missing space after # in heading'}, {
'pattern': r'^#+[A-Za-z0-9]',
'strip': '\n',
'description': 'Missing space after # in heading',
},
] ]
python_rules = RuleList( python_rules = RuleList(
langs=['py'], langs=['py'],
rules=[ rules=[
{'pattern': r'".*"%\([a-z_].*\)?$', {'pattern': r'".*"%\([a-z_].*\)?$', 'description': 'Missing space around "%"'},
'description': 'Missing space around "%"'}, {'pattern': r"'.*'%\([a-z_].*\)?$", 'description': 'Missing space around "%"'},
{'pattern': r"'.*'%\([a-z_].*\)?$",
'description': 'Missing space around "%"'},
# This rule is constructed with + to avoid triggering on itself # This rule is constructed with + to avoid triggering on itself
{'pattern': r" =" + r'[^ =>~"]', {'pattern': r" =" + r'[^ =>~"]', 'description': 'Missing whitespace after "="'},
'description': 'Missing whitespace after "="'}, {'pattern': r'":\w[^"]*$', 'description': 'Missing whitespace after ":"'},
{'pattern': r'":\w[^"]*$', {'pattern': r"':\w[^']*$", 'description': 'Missing whitespace after ":"'},
'description': 'Missing whitespace after ":"'}, {'pattern': r"^\s+[#]\w", 'strip': '\n', 'description': 'Missing whitespace after "#"'},
{'pattern': r"':\w[^']*$", {
'description': 'Missing whitespace after ":"'}, 'pattern': r"assertEquals[(]",
{'pattern': r"^\s+[#]\w", 'description': 'Use assertEqual, not assertEquals (which is deprecated).',
'strip': '\n', },
'description': 'Missing whitespace after "#"'}, {
{'pattern': r"assertEquals[(]", 'pattern': r'self: Any',
'description': 'Use assertEqual, not assertEquals (which is deprecated).'}, 'description': 'you can omit Any annotation for self',
{'pattern': r'self: Any', 'good_lines': ['def foo (self):'],
'description': 'you can omit Any annotation for self', 'bad_lines': ['def foo(self: Any):'],
'good_lines': ['def foo (self):'], },
'bad_lines': ['def foo(self: Any):']}, {'pattern': r"== None", 'description': 'Use `is None` to check whether something is None'},
{'pattern': r"== None", {'pattern': r"type:[(]", 'description': 'Missing whitespace after ":" in type annotation'},
'description': 'Use `is None` to check whether something is None'}, {'pattern': r"# type [(]", 'description': 'Missing : after type in type annotation'},
{'pattern': r"type:[(]", {'pattern': r"#type", 'description': 'Missing whitespace after "#" in type annotation'},
'description': 'Missing whitespace after ":" in type annotation'}, {'pattern': r'if[(]', 'description': 'Missing space between if and ('},
{'pattern': r"# type [(]", {'pattern': r", [)]", 'description': 'Unnecessary whitespace between "," and ")"'},
'description': 'Missing : after type in type annotation'}, {'pattern': r"% [(]", 'description': 'Unnecessary whitespace between "%" and "("'},
{'pattern': r"#type",
'description': 'Missing whitespace after "#" in type annotation'},
{'pattern': r'if[(]',
'description': 'Missing space between if and ('},
{'pattern': r", [)]",
'description': 'Unnecessary whitespace between "," and ")"'},
{'pattern': r"% [(]",
'description': 'Unnecessary whitespace between "%" and "("'},
# This next check could have false positives, but it seems pretty # This next check could have false positives, but it seems pretty
# rare; if we find any, they can be added to the exclude list for # rare; if we find any, they can be added to the exclude list for
# this rule. # this rule.
{'pattern': r' % [a-zA-Z0-9_.]*\)?$', {
'description': 'Used % comprehension without a tuple'}, 'pattern': r' % [a-zA-Z0-9_.]*\)?$',
{'pattern': r'.*%s.* % \([a-zA-Z0-9_.]*\)$', 'description': 'Used % comprehension without a tuple',
'description': 'Used % comprehension without a tuple'}, },
{'pattern': r'__future__', {
'include_only': {'zulip_bots/zulip_bots/bots/'}, 'pattern': r'.*%s.* % \([a-zA-Z0-9_.]*\)$',
'description': 'Bots no longer need __future__ imports.'}, 'description': 'Used % comprehension without a tuple',
{'pattern': r'#!/usr/bin/env python$', },
'include_only': {'zulip_bots/'}, {
'description': 'Python shebangs must be python3'}, 'pattern': r'__future__',
{'pattern': r'(^|\s)open\s*\(', 'include_only': {'zulip_bots/zulip_bots/bots/'},
'description': 'open() should not be used in Zulip\'s bots. Use functions' 'description': 'Bots no longer need __future__ imports.',
' provided by the bots framework to access the filesystem.', },
'include_only': {'zulip_bots/zulip_bots/bots/'}}, {
{'pattern': r'pprint', 'pattern': r'#!/usr/bin/env python$',
'description': 'Used pprint, which is most likely a debugging leftover. For user output, use print().'}, 'include_only': {'zulip_bots/'},
{'pattern': r'\(BotTestCase\)', 'description': 'Python shebangs must be python3',
'bad_lines': ['class TestSomeBot(BotTestCase):'], },
'description': 'Bot test cases should directly inherit from BotTestCase *and* DefaultTests.'}, {
{'pattern': r'\(DefaultTests, BotTestCase\)', 'pattern': r'(^|\s)open\s*\(',
'bad_lines': ['class TestSomeBot(DefaultTests, BotTestCase):'], 'description': 'open() should not be used in Zulip\'s bots. Use functions'
'good_lines': ['class TestSomeBot(BotTestCase, DefaultTests):'], ' provided by the bots framework to access the filesystem.',
'description': 'Bot test cases should inherit from BotTestCase before DefaultTests.'}, 'include_only': {'zulip_bots/zulip_bots/bots/'},
},
{
'pattern': r'pprint',
'description': 'Used pprint, which is most likely a debugging leftover. For user output, use print().',
},
{
'pattern': r'\(BotTestCase\)',
'bad_lines': ['class TestSomeBot(BotTestCase):'],
'description': 'Bot test cases should directly inherit from BotTestCase *and* DefaultTests.',
},
{
'pattern': r'\(DefaultTests, BotTestCase\)',
'bad_lines': ['class TestSomeBot(DefaultTests, BotTestCase):'],
'good_lines': ['class TestSomeBot(BotTestCase, DefaultTests):'],
'description': 'Bot test cases should inherit from BotTestCase before DefaultTests.',
},
*whitespace_rules, *whitespace_rules,
], ],
max_length=140, max_length=140,
@ -99,9 +107,11 @@ python_rules = RuleList(
bash_rules = RuleList( bash_rules = RuleList(
langs=['sh'], langs=['sh'],
rules=[ rules=[
{'pattern': r'#!.*sh [-xe]', {
'description': 'Fix shebang line with proper call to /usr/bin/env for Bash path, change -x|-e switches' 'pattern': r'#!.*sh [-xe]',
' to set -x|set -e'}, 'description': 'Fix shebang line with proper call to /usr/bin/env for Bash path, change -x|-e switches'
' to set -x|set -e',
},
*whitespace_rules[0:1], *whitespace_rules[0:1],
], ],
) )
@ -116,20 +126,27 @@ json_rules = RuleList(
# version of the tab-based whitespace rule (we can't just use # version of the tab-based whitespace rule (we can't just use
# exclude in whitespace_rules, since we only want to ignore # exclude in whitespace_rules, since we only want to ignore
# JSON files with tab-based whitespace, not webhook code). # JSON files with tab-based whitespace, not webhook code).
rules= whitespace_rules[0:1], rules=whitespace_rules[0:1],
) )
prose_style_rules = [ prose_style_rules = [
{'pattern': r'[^\/\#\-"]([jJ]avascript)', # exclude usage in hrefs/divs {
'description': "javascript should be spelled JavaScript"}, 'pattern': r'[^\/\#\-"]([jJ]avascript)', # exclude usage in hrefs/divs
{'pattern': r'''[^\/\-\."'\_\=\>]([gG]ithub)[^\.\-\_"\<]''', # exclude usage in hrefs/divs 'description': "javascript should be spelled JavaScript",
'description': "github should be spelled GitHub"}, },
{'pattern': r'[oO]rganisation', # exclude usage in hrefs/divs {
'description': "Organization is spelled with a z"}, 'pattern': r'''[^\/\-\."'\_\=\>]([gG]ithub)[^\.\-\_"\<]''', # exclude usage in hrefs/divs
{'pattern': r'!!! warning', 'description': "github should be spelled GitHub",
'description': "!!! warning is invalid; it's spelled '!!! warn'"}, },
{'pattern': r'[^-_]botserver(?!rc)|bot server', {
'description': "Use Botserver instead of botserver or Botserver."}, 'pattern': r'[oO]rganisation', # exclude usage in hrefs/divs
'description': "Organization is spelled with a z",
},
{'pattern': r'!!! warning', 'description': "!!! warning is invalid; it's spelled '!!! warn'"},
{
'pattern': r'[^-_]botserver(?!rc)|bot server',
'description': "Use Botserver instead of botserver or Botserver.",
},
] # type: List[Rule] ] # type: List[Rule]
markdown_docs_length_exclude = { markdown_docs_length_exclude = {
@ -141,8 +158,10 @@ markdown_rules = RuleList(
rules=[ rules=[
*markdown_whitespace_rules, *markdown_whitespace_rules,
*prose_style_rules, *prose_style_rules,
{'pattern': r'\[(?P<url>[^\]]+)\]\((?P=url)\)', {
'description': 'Linkified markdown URLs should use cleaner <http://example.com> syntax.'} 'pattern': r'\[(?P<url>[^\]]+)\]\((?P=url)\)',
'description': 'Linkified markdown URLs should use cleaner <http://example.com> syntax.',
},
], ],
max_length=120, max_length=120,
length_exclude=markdown_docs_length_exclude, length_exclude=markdown_docs_length_exclude,

View file

@ -18,6 +18,7 @@ bold = '\033[1m' # type: str
bots_dir = '.bots' # type: str bots_dir = '.bots' # type: str
def pack(options: argparse.Namespace) -> None: def pack(options: argparse.Namespace) -> None:
# Basic sanity checks for input. # Basic sanity checks for input.
if not options.path: if not options.path:
@ -53,15 +54,20 @@ def pack(options: argparse.Namespace) -> None:
# Pack the zuliprc # Pack the zuliprc
zip_file.write(options.config, 'zuliprc') zip_file.write(options.config, 'zuliprc')
# Pack the config file for the botfarm. # Pack the config file for the botfarm.
bot_config = textwrap.dedent('''\ bot_config = textwrap.dedent(
'''\
[deploy] [deploy]
bot={} bot={}
zuliprc=zuliprc zuliprc=zuliprc
'''.format(options.main)) '''.format(
options.main
)
)
zip_file.writestr('config.ini', bot_config) zip_file.writestr('config.ini', bot_config)
zip_file.close() zip_file.close()
print('pack: Created zip file at: {}.'.format(zip_file_path)) print('pack: Created zip file at: {}.'.format(zip_file_path))
def check_common_options(options: argparse.Namespace) -> None: def check_common_options(options: argparse.Namespace) -> None:
if not options.server: if not options.server:
print('tools/deploy: URL to Botfarm server not specified.') print('tools/deploy: URL to Botfarm server not specified.')
@ -70,18 +76,20 @@ def check_common_options(options: argparse.Namespace) -> None:
print('tools/deploy: Botfarm deploy token not specified.') print('tools/deploy: Botfarm deploy token not specified.')
sys.exit(1) sys.exit(1)
def handle_common_response_without_data(response: Response,
operation: str, def handle_common_response_without_data(
success_message: str) -> bool: response: Response, operation: str, success_message: str
) -> bool:
return handle_common_response( return handle_common_response(
response=response, response=response,
operation=operation, operation=operation,
success_handler=lambda r: print('{}: {}'.format(operation, success_message)) success_handler=lambda r: print('{}: {}'.format(operation, success_message)),
) )
def handle_common_response(response: Response,
operation: str, def handle_common_response(
success_handler: Callable[[Dict[str, Any]], Any]) -> bool: response: Response, operation: str, success_handler: Callable[[Dict[str, Any]], Any]
) -> bool:
if response.status_code == requests.codes.ok: if response.status_code == requests.codes.ok:
response_data = response.json() response_data = response.json()
if response_data['status'] == 'success': if response_data['status'] == 'success':
@ -99,6 +107,7 @@ def handle_common_response(response: Response,
print('{}: Error {}. Aborting.'.format(operation, response.status_code)) print('{}: Error {}. Aborting.'.format(operation, response.status_code))
return False return False
def upload(options: argparse.Namespace) -> None: def upload(options: argparse.Namespace) -> None:
check_common_options(options) check_common_options(options)
file_path = os.path.join(bots_dir, options.botname + '.zip') file_path = os.path.join(bots_dir, options.botname + '.zip')
@ -109,10 +118,13 @@ def upload(options: argparse.Namespace) -> None:
headers = {'key': options.token} headers = {'key': options.token}
url = urllib.parse.urljoin(options.server, 'bots/upload') url = urllib.parse.urljoin(options.server, 'bots/upload')
response = requests.post(url, files=files, headers=headers) response = requests.post(url, files=files, headers=headers)
result = handle_common_response_without_data(response, 'upload', 'Uploaded the bot package to botfarm.') result = handle_common_response_without_data(
response, 'upload', 'Uploaded the bot package to botfarm.'
)
if result is False: if result is False:
sys.exit(1) sys.exit(1)
def clean(options: argparse.Namespace) -> None: def clean(options: argparse.Namespace) -> None:
file_path = os.path.join(bots_dir, options.botname + '.zip') file_path = os.path.join(bots_dir, options.botname + '.zip')
if os.path.exists(file_path): if os.path.exists(file_path):
@ -121,42 +133,53 @@ def clean(options: argparse.Namespace) -> None:
else: else:
print('clean: File \'{}\' not found.'.format(file_path)) print('clean: File \'{}\' not found.'.format(file_path))
def process(options: argparse.Namespace) -> None: def process(options: argparse.Namespace) -> None:
check_common_options(options) check_common_options(options)
headers = {'key': options.token} headers = {'key': options.token}
url = urllib.parse.urljoin(options.server, 'bots/process') url = urllib.parse.urljoin(options.server, 'bots/process')
payload = {'name': options.botname} payload = {'name': options.botname}
response = requests.post(url, headers=headers, json=payload) response = requests.post(url, headers=headers, json=payload)
result = handle_common_response_without_data(response, 'process', 'The bot has been processed by the botfarm.') result = handle_common_response_without_data(
response, 'process', 'The bot has been processed by the botfarm.'
)
if result is False: if result is False:
sys.exit(1) sys.exit(1)
def start(options: argparse.Namespace) -> None: def start(options: argparse.Namespace) -> None:
check_common_options(options) check_common_options(options)
headers = {'key': options.token} headers = {'key': options.token}
url = urllib.parse.urljoin(options.server, 'bots/start') url = urllib.parse.urljoin(options.server, 'bots/start')
payload = {'name': options.botname} payload = {'name': options.botname}
response = requests.post(url, headers=headers, json=payload) response = requests.post(url, headers=headers, json=payload)
result = handle_common_response_without_data(response, 'start', 'The bot has been started by the botfarm.') result = handle_common_response_without_data(
response, 'start', 'The bot has been started by the botfarm.'
)
if result is False: if result is False:
sys.exit(1) sys.exit(1)
def stop(options: argparse.Namespace) -> None: def stop(options: argparse.Namespace) -> None:
check_common_options(options) check_common_options(options)
headers = {'key': options.token} headers = {'key': options.token}
url = urllib.parse.urljoin(options.server, 'bots/stop') url = urllib.parse.urljoin(options.server, 'bots/stop')
payload = {'name': options.botname} payload = {'name': options.botname}
response = requests.post(url, headers=headers, json=payload) response = requests.post(url, headers=headers, json=payload)
result = handle_common_response_without_data(response, 'stop', 'The bot has been stopped by the botfarm.') result = handle_common_response_without_data(
response, 'stop', 'The bot has been stopped by the botfarm.'
)
if result is False: if result is False:
sys.exit(1) sys.exit(1)
def prepare(options: argparse.Namespace) -> None: def prepare(options: argparse.Namespace) -> None:
pack(options) pack(options)
upload(options) upload(options)
clean(options) clean(options)
process(options) process(options)
def log(options: argparse.Namespace) -> None: def log(options: argparse.Namespace) -> None:
check_common_options(options) check_common_options(options)
headers = {'key': options.token} headers = {'key': options.token}
@ -171,16 +194,20 @@ def log(options: argparse.Namespace) -> None:
if result is False: if result is False:
sys.exit(1) sys.exit(1)
def delete(options: argparse.Namespace) -> None: def delete(options: argparse.Namespace) -> None:
check_common_options(options) check_common_options(options)
headers = {'key': options.token} headers = {'key': options.token}
url = urllib.parse.urljoin(options.server, 'bots/delete') url = urllib.parse.urljoin(options.server, 'bots/delete')
payload = {'name': options.botname} payload = {'name': options.botname}
response = requests.post(url, headers=headers, json=payload) response = requests.post(url, headers=headers, json=payload)
result = handle_common_response_without_data(response, 'delete', 'The bot has been removed from the botfarm.') result = handle_common_response_without_data(
response, 'delete', 'The bot has been removed from the botfarm.'
)
if result is False: if result is False:
sys.exit(1) sys.exit(1)
def list_bots(options: argparse.Namespace) -> None: def list_bots(options: argparse.Namespace) -> None:
check_common_options(options) check_common_options(options)
headers = {'key': options.token} headers = {'key': options.token}
@ -190,10 +217,13 @@ def list_bots(options: argparse.Namespace) -> None:
pretty_print = False pretty_print = False
url = urllib.parse.urljoin(options.server, 'bots/list') url = urllib.parse.urljoin(options.server, 'bots/list')
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers)
result = handle_common_response(response, 'ls', lambda r: print_bots(r['bots']['list'], pretty_print)) result = handle_common_response(
response, 'ls', lambda r: print_bots(r['bots']['list'], pretty_print)
)
if result is False: if result is False:
sys.exit(1) sys.exit(1)
def print_bots(bots: List[Any], pretty_print: bool) -> None: def print_bots(bots: List[Any], pretty_print: bool) -> None:
if pretty_print: if pretty_print:
print_bots_pretty(bots) print_bots_pretty(bots)
@ -201,6 +231,7 @@ def print_bots(bots: List[Any], pretty_print: bool) -> None:
for bot in bots: for bot in bots:
print('{}\t{}\t{}\t{}'.format(bot['name'], bot['status'], bot['email'], bot['site'])) print('{}\t{}\t{}\t{}'.format(bot['name'], bot['status'], bot['email'], bot['site']))
def print_bots_pretty(bots: List[Any]) -> None: def print_bots_pretty(bots: List[Any]) -> None:
if len(bots) == 0: if len(bots) == 0:
print('ls: No bots found on the botfarm') print('ls: No bots found on the botfarm')
@ -231,6 +262,7 @@ def print_bots_pretty(bots: List[Any]) -> None:
) )
print(row) print(row)
def main() -> None: def main() -> None:
usage = """tools/deploy <command> <bot-name> [options] usage = """tools/deploy <command> <bot-name> [options]
@ -267,23 +299,25 @@ To list user's bots, use:
parser = argparse.ArgumentParser(usage=usage) parser = argparse.ArgumentParser(usage=usage)
parser.add_argument('command', help='Command to run.') parser.add_argument('command', help='Command to run.')
parser.add_argument('botname', nargs='?', help='Name of bot to operate on.') parser.add_argument('botname', nargs='?', help='Name of bot to operate on.')
parser.add_argument('--server', '-s', parser.add_argument(
metavar='SERVERURL', '--server',
default=os.environ.get('SERVER', ''), '-s',
help='Url of the Zulip Botfarm server.') metavar='SERVERURL',
parser.add_argument('--token', '-t', default=os.environ.get('SERVER', ''),
default=os.environ.get('TOKEN', ''), help='Url of the Zulip Botfarm server.',
help='Deploy Token for the Botfarm.') )
parser.add_argument('--path', '-p', parser.add_argument(
help='Path to the bot directory.') '--token', '-t', default=os.environ.get('TOKEN', ''), help='Deploy Token for the Botfarm.'
parser.add_argument('--config', '-c', )
help='Path to the zuliprc file.') parser.add_argument('--path', '-p', help='Path to the bot directory.')
parser.add_argument('--main', '-m', parser.add_argument('--config', '-c', help='Path to the zuliprc file.')
help='Path to the bot\'s main file, relative to the bot\'s directory.') parser.add_argument(
parser.add_argument('--lines', '-l', '--main', '-m', help='Path to the bot\'s main file, relative to the bot\'s directory.'
help='Number of lines in log required.') )
parser.add_argument('--format', '-f', action='store_true', parser.add_argument('--lines', '-l', help='Number of lines in log required.')
help='Print user\'s bots in human readable format') parser.add_argument(
'--format', '-f', action='store_true', help='Print user\'s bots in human readable format'
)
options = parser.parse_args() options = parser.parse_args()
if not options.command: if not options.command:
print('tools/deploy: No command specified.') print('tools/deploy: No command specified.')
@ -308,5 +342,7 @@ To list user's bots, use:
commands[options.command](options) commands[options.command](options)
else: else:
print('tools/deploy: No command \'{}\' found.'.format(options.command)) print('tools/deploy: No command \'{}\' found.'.format(options.command))
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View file

@ -11,84 +11,253 @@ from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
# License: MIT # License: MIT
# Ref: fit_commit/validators/tense.rb # Ref: fit_commit/validators/tense.rb
WORD_SET = { WORD_SET = {
'adds', 'adding', 'added', 'adds',
'allows', 'allowing', 'allowed', 'adding',
'amends', 'amending', 'amended', 'added',
'bumps', 'bumping', 'bumped', 'allows',
'calculates', 'calculating', 'calculated', 'allowing',
'changes', 'changing', 'changed', 'allowed',
'cleans', 'cleaning', 'cleaned', 'amends',
'commits', 'committing', 'committed', 'amending',
'corrects', 'correcting', 'corrected', 'amended',
'creates', 'creating', 'created', 'bumps',
'darkens', 'darkening', 'darkened', 'bumping',
'disables', 'disabling', 'disabled', 'bumped',
'displays', 'displaying', 'displayed', 'calculates',
'documents', 'documenting', 'documented', 'calculating',
'drys', 'drying', 'dryed', 'calculated',
'ends', 'ending', 'ended', 'changes',
'enforces', 'enforcing', 'enforced', 'changing',
'enqueues', 'enqueuing', 'enqueued', 'changed',
'extracts', 'extracting', 'extracted', 'cleans',
'finishes', 'finishing', 'finished', 'cleaning',
'fixes', 'fixing', 'fixed', 'cleaned',
'formats', 'formatting', 'formatted', 'commits',
'guards', 'guarding', 'guarded', 'committing',
'handles', 'handling', 'handled', 'committed',
'hides', 'hiding', 'hid', 'corrects',
'increases', 'increasing', 'increased', 'correcting',
'ignores', 'ignoring', 'ignored', 'corrected',
'implements', 'implementing', 'implemented', 'creates',
'improves', 'improving', 'improved', 'creating',
'keeps', 'keeping', 'kept', 'created',
'kills', 'killing', 'killed', 'darkens',
'makes', 'making', 'made', 'darkening',
'merges', 'merging', 'merged', 'darkened',
'moves', 'moving', 'moved', 'disables',
'permits', 'permitting', 'permitted', 'disabling',
'prevents', 'preventing', 'prevented', 'disabled',
'pushes', 'pushing', 'pushed', 'displays',
'rebases', 'rebasing', 'rebased', 'displaying',
'refactors', 'refactoring', 'refactored', 'displayed',
'removes', 'removing', 'removed', 'documents',
'renames', 'renaming', 'renamed', 'documenting',
'reorders', 'reordering', 'reordered', 'documented',
'replaces', 'replacing', 'replaced', 'drys',
'requires', 'requiring', 'required', 'drying',
'restores', 'restoring', 'restored', 'dryed',
'sends', 'sending', 'sent', 'ends',
'sets', 'setting', 'ending',
'separates', 'separating', 'separated', 'ended',
'shows', 'showing', 'showed', 'enforces',
'simplifies', 'simplifying', 'simplified', 'enforcing',
'skips', 'skipping', 'skipped', 'enforced',
'sorts', 'sorting', 'enqueues',
'speeds', 'speeding', 'sped', 'enqueuing',
'starts', 'starting', 'started', 'enqueued',
'supports', 'supporting', 'supported', 'extracts',
'takes', 'taking', 'took', 'extracting',
'testing', 'tested', # 'tests' excluded to reduce false negative 'extracted',
'truncates', 'truncating', 'truncated', 'finishes',
'updates', 'updating', 'updated', 'finishing',
'uses', 'using', 'used', 'finished',
'fixes',
'fixing',
'fixed',
'formats',
'formatting',
'formatted',
'guards',
'guarding',
'guarded',
'handles',
'handling',
'handled',
'hides',
'hiding',
'hid',
'increases',
'increasing',
'increased',
'ignores',
'ignoring',
'ignored',
'implements',
'implementing',
'implemented',
'improves',
'improving',
'improved',
'keeps',
'keeping',
'kept',
'kills',
'killing',
'killed',
'makes',
'making',
'made',
'merges',
'merging',
'merged',
'moves',
'moving',
'moved',
'permits',
'permitting',
'permitted',
'prevents',
'preventing',
'prevented',
'pushes',
'pushing',
'pushed',
'rebases',
'rebasing',
'rebased',
'refactors',
'refactoring',
'refactored',
'removes',
'removing',
'removed',
'renames',
'renaming',
'renamed',
'reorders',
'reordering',
'reordered',
'replaces',
'replacing',
'replaced',
'requires',
'requiring',
'required',
'restores',
'restoring',
'restored',
'sends',
'sending',
'sent',
'sets',
'setting',
'separates',
'separating',
'separated',
'shows',
'showing',
'showed',
'simplifies',
'simplifying',
'simplified',
'skips',
'skipping',
'skipped',
'sorts',
'sorting',
'speeds',
'speeding',
'sped',
'starts',
'starting',
'started',
'supports',
'supporting',
'supported',
'takes',
'taking',
'took',
'testing',
'tested', # 'tests' excluded to reduce false negative
'truncates',
'truncating',
'truncated',
'updates',
'updating',
'updated',
'uses',
'using',
'used',
} }
imperative_forms = [ imperative_forms = [
'add', 'allow', 'amend', 'bump', 'calculate', 'change', 'clean', 'commit', 'add',
'correct', 'create', 'darken', 'disable', 'display', 'document', 'dry', 'allow',
'end', 'enforce', 'enqueue', 'extract', 'finish', 'fix', 'format', 'guard', 'amend',
'handle', 'hide', 'ignore', 'implement', 'improve', 'increase', 'keep', 'bump',
'kill', 'make', 'merge', 'move', 'permit', 'prevent', 'push', 'rebase', 'calculate',
'refactor', 'remove', 'rename', 'reorder', 'replace', 'require', 'restore', 'change',
'send', 'separate', 'set', 'show', 'simplify', 'skip', 'sort', 'speed', 'clean',
'start', 'support', 'take', 'test', 'truncate', 'update', 'use', 'commit',
'correct',
'create',
'darken',
'disable',
'display',
'document',
'dry',
'end',
'enforce',
'enqueue',
'extract',
'finish',
'fix',
'format',
'guard',
'handle',
'hide',
'ignore',
'implement',
'improve',
'increase',
'keep',
'kill',
'make',
'merge',
'move',
'permit',
'prevent',
'push',
'rebase',
'refactor',
'remove',
'rename',
'reorder',
'replace',
'require',
'restore',
'send',
'separate',
'set',
'show',
'simplify',
'skip',
'sort',
'speed',
'start',
'support',
'take',
'test',
'truncate',
'update',
'use',
] ]
imperative_forms.sort() imperative_forms.sort()
def head_binary_search(key: str, words: List[str]) -> str: def head_binary_search(key: str, words: List[str]) -> str:
""" Find the imperative mood version of `word` by looking at the first """Find the imperative mood version of `word` by looking at the first
3 characters. """ 3 characters."""
# Edge case: 'disable' and 'display' have the same 3 starting letters. # Edge case: 'disable' and 'display' have the same 3 starting letters.
if key in ['displays', 'displaying', 'displayed']: if key in ['displays', 'displaying', 'displayed']:
@ -114,16 +283,18 @@ def head_binary_search(key: str, words: List[str]) -> str:
class ImperativeMood(LineRule): class ImperativeMood(LineRule):
""" This rule will enforce that the commit message title uses imperative """This rule will enforce that the commit message title uses imperative
mood. This is done by checking if the first word is in `WORD_SET`, if so mood. This is done by checking if the first word is in `WORD_SET`, if so
show the word in the correct mood. """ show the word in the correct mood."""
name = "title-imperative-mood" name = "title-imperative-mood"
id = "Z1" id = "Z1"
target = CommitMessageTitle target = CommitMessageTitle
error_msg = ('The first word in commit title should be in imperative mood ' error_msg = (
'("{word}" -> "{imperative}"): "{title}"') 'The first word in commit title should be in imperative mood '
'("{word}" -> "{imperative}"): "{title}"'
)
def validate(self, line: str, commit: GitCommit) -> List[RuleViolation]: def validate(self, line: str, commit: GitCommit) -> List[RuleViolation]:
violations = [] violations = []
@ -134,11 +305,14 @@ class ImperativeMood(LineRule):
if first_word in WORD_SET: if first_word in WORD_SET:
imperative = head_binary_search(first_word, imperative_forms) imperative = head_binary_search(first_word, imperative_forms)
violation = RuleViolation(self.id, self.error_msg.format( violation = RuleViolation(
word=first_word, self.id,
imperative=imperative, self.error_msg.format(
title=commit.message.title, word=first_word,
)) imperative=imperative,
title=commit.message.title,
),
)
violations.append(violation) violations.append(violation)

View file

@ -12,6 +12,7 @@ EXCLUDED_FILES = [
'zulip/integrations/perforce/git_p4.py', 'zulip/integrations/perforce/git_p4.py',
] ]
def run() -> None: def run() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
add_default_linter_arguments(parser) add_default_linter_arguments(parser)
@ -19,15 +20,23 @@ def run() -> None:
linter_config = LinterConfig(args) linter_config = LinterConfig(args)
by_lang = linter_config.list_files(file_types=['py', 'sh', 'json', 'md', 'txt'], by_lang = linter_config.list_files(
exclude=EXCLUDED_FILES) file_types=['py', 'sh', 'json', 'md', 'txt'], exclude=EXCLUDED_FILES
)
linter_config.external_linter('mypy', [sys.executable, 'tools/run-mypy'], ['py'], pass_targets=False, linter_config.external_linter(
description="Static type checker for Python (config: mypy.ini)") 'mypy',
linter_config.external_linter('flake8', ['flake8'], ['py'], [sys.executable, 'tools/run-mypy'],
description="Standard Python linter (config: .flake8)") ['py'],
linter_config.external_linter('gitlint', ['tools/lint-commits'], pass_targets=False,
description="Git Lint for commit messages") description="Static type checker for Python (config: mypy.ini)",
)
linter_config.external_linter(
'flake8', ['flake8'], ['py'], description="Standard Python linter (config: .flake8)"
)
linter_config.external_linter(
'gitlint', ['tools/lint-commits'], description="Git Lint for commit messages"
)
@linter_config.lint @linter_config.lint
def custom_py() -> int: def custom_py() -> int:
@ -45,5 +54,6 @@ def run() -> None:
linter_config.do_lint() linter_config.do_lint()
if __name__ == '__main__': if __name__ == '__main__':
run() run()

View file

@ -15,32 +15,39 @@ green = '\033[92m'
end_format = '\033[0m' end_format = '\033[0m'
bold = '\033[1m' bold = '\033[1m'
def main(): def main():
usage = """./tools/provision usage = """./tools/provision
Creates a Python virtualenv. Its Python version is equal to Creates a Python virtualenv. Its Python version is equal to
the Python version this command is executed with.""" the Python version this command is executed with."""
parser = argparse.ArgumentParser(usage=usage) parser = argparse.ArgumentParser(usage=usage)
parser.add_argument('--python-interpreter', '-p', parser.add_argument(
metavar='PATH_TO_PYTHON_INTERPRETER', '--python-interpreter',
default=os.path.abspath(sys.executable), '-p',
help='Path to the Python interpreter to use when provisioning.') metavar='PATH_TO_PYTHON_INTERPRETER',
parser.add_argument('--force', '-f', action='store_true', default=os.path.abspath(sys.executable),
help='create venv even with outdated Python version.') help='Path to the Python interpreter to use when provisioning.',
)
parser.add_argument(
'--force', '-f', action='store_true', help='create venv even with outdated Python version.'
)
options = parser.parse_args() options = parser.parse_args()
base_dir = os.path.abspath(os.path.join(__file__, '..', '..')) base_dir = os.path.abspath(os.path.join(__file__, '..', '..'))
py_version_output = subprocess.check_output([options.python_interpreter, '--version'], py_version_output = subprocess.check_output(
stderr=subprocess.STDOUT, universal_newlines=True) [options.python_interpreter, '--version'], stderr=subprocess.STDOUT, universal_newlines=True
)
# The output has the format "Python 1.2.3" # The output has the format "Python 1.2.3"
py_version_list = py_version_output.split()[1].split('.') py_version_list = py_version_output.split()[1].split('.')
py_version = tuple(int(num) for num in py_version_list[0:2]) py_version = tuple(int(num) for num in py_version_list[0:2])
venv_name = 'zulip-api-py{}-venv'.format(py_version[0]) venv_name = 'zulip-api-py{}-venv'.format(py_version[0])
if py_version <= (3, 1) and (not options.force): if py_version <= (3, 1) and (not options.force):
print(red + "Provision failed: Cannot create venv with outdated Python version ({}).\n" print(
"Maybe try `python3 tools/provision`." red + "Provision failed: Cannot create venv with outdated Python version ({}).\n"
.format(py_version_output.strip()) + end_format) "Maybe try `python3 tools/provision`.".format(py_version_output.strip()) + end_format
)
sys.exit(1) sys.exit(1)
venv_dir = os.path.join(base_dir, venv_name) venv_dir = os.path.join(base_dir, venv_name)
@ -48,18 +55,24 @@ the Python version this command is executed with."""
try: try:
return_code = subprocess.call([options.python_interpreter, '-m', 'venv', venv_dir]) return_code = subprocess.call([options.python_interpreter, '-m', 'venv', venv_dir])
except OSError: except OSError:
print("{red}Installation with venv failed. Probable errors are: " print(
"You are on Ubuntu and you haven't installed python3-venv," "{red}Installation with venv failed. Probable errors are: "
"or you are running an unsupported python version" "You are on Ubuntu and you haven't installed python3-venv,"
"or python is not installed properly{end_format}" "or you are running an unsupported python version"
.format(red=red, end_format=end_format)) "or python is not installed properly{end_format}".format(
red=red, end_format=end_format
)
)
sys.exit(1) sys.exit(1)
raise raise
else: else:
# subprocess.call returns 0 if a script executed successfully # subprocess.call returns 0 if a script executed successfully
if return_code: if return_code:
raise OSError("The command `{} -m venv {}` failed. Virtualenv not created!" raise OSError(
.format(options.python_interpreter, venv_dir)) "The command `{} -m venv {}` failed. Virtualenv not created!".format(
options.python_interpreter, venv_dir
)
)
print("New virtualenv created.") print("New virtualenv created.")
else: else:
print("Virtualenv already exists.") print("Virtualenv already exists.")
@ -85,10 +98,21 @@ the Python version this command is executed with."""
pip_path = os.path.join(venv_dir, venv_exec_dir, 'pip') pip_path = os.path.join(venv_dir, venv_exec_dir, 'pip')
# We first install a modern version of pip that supports --prefix # We first install a modern version of pip that supports --prefix
subprocess.call([pip_path, 'install', 'pip>=9.0']) subprocess.call([pip_path, 'install', 'pip>=9.0'])
if subprocess.call([pip_path, 'install', '--prefix', venv_dir, '-r', if subprocess.call(
os.path.join(base_dir, requirements_filename)]): [
raise OSError("The command `pip install -r {}` failed. Dependencies not installed!" pip_path,
.format(os.path.join(base_dir, requirements_filename))) 'install',
'--prefix',
venv_dir,
'-r',
os.path.join(base_dir, requirements_filename),
]
):
raise OSError(
"The command `pip install -r {}` failed. Dependencies not installed!".format(
os.path.join(base_dir, requirements_filename)
)
)
install_dependencies('requirements.txt') install_dependencies('requirements.txt')
@ -105,10 +129,7 @@ the Python version this command is executed with."""
print(green + 'Success!' + end_format) print(green + 'Success!' + end_format)
activate_command = os.path.join(base_dir, activate_command = os.path.join(base_dir, venv_dir, venv_exec_dir, 'activate')
venv_dir,
venv_exec_dir,
'activate')
# We make the path look like a Unix path, because most Windows users # We make the path look like a Unix path, because most Windows users
# are likely to be running in a bash shell. # are likely to be running in a bash shell.
activate_command = activate_command.replace(os.sep, '/') activate_command = activate_command.replace(os.sep, '/')

View file

@ -13,6 +13,7 @@ import twine.commands.upload
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@contextmanager @contextmanager
def cd(newdir): def cd(newdir):
prevdir = os.getcwd() prevdir = os.getcwd()
@ -22,6 +23,7 @@ def cd(newdir):
finally: finally:
os.chdir(prevdir) os.chdir(prevdir)
def _generate_dist(dist_type, setup_file, package_name, setup_args): def _generate_dist(dist_type, setup_file, package_name, setup_args):
message = 'Generating {dist_type} for {package_name}.'.format( message = 'Generating {dist_type} for {package_name}.'.format(
dist_type=dist_type, dist_type=dist_type,
@ -40,13 +42,13 @@ def _generate_dist(dist_type, setup_file, package_name, setup_args):
) )
print(crayons.green(message, bold=True)) print(crayons.green(message, bold=True))
def generate_bdist_wheel(setup_file, package_name, universal=False): def generate_bdist_wheel(setup_file, package_name, universal=False):
if universal: if universal:
_generate_dist('bdist_wheel', setup_file, package_name, _generate_dist('bdist_wheel', setup_file, package_name, ['bdist_wheel', '--universal'])
['bdist_wheel', '--universal'])
else: else:
_generate_dist('bdist_wheel', setup_file, package_name, _generate_dist('bdist_wheel', setup_file, package_name, ['bdist_wheel'])
['bdist_wheel'])
def twine_upload(dist_dirs): def twine_upload(dist_dirs):
message = 'Uploading distributions under the following directories:' message = 'Uploading distributions under the following directories:'
@ -55,14 +57,12 @@ def twine_upload(dist_dirs):
print(crayons.yellow(dist_dir)) print(crayons.yellow(dist_dir))
twine.commands.upload.main(dist_dirs) twine.commands.upload.main(dist_dirs)
def cleanup(package_dir): def cleanup(package_dir):
build_dir = os.path.join(package_dir, 'build') build_dir = os.path.join(package_dir, 'build')
temp_dir = os.path.join(package_dir, 'temp') temp_dir = os.path.join(package_dir, 'temp')
dist_dir = os.path.join(package_dir, 'dist') dist_dir = os.path.join(package_dir, 'dist')
egg_info = os.path.join( egg_info = os.path.join(package_dir, '{}.egg-info'.format(os.path.basename(package_dir)))
package_dir,
'{}.egg-info'.format(os.path.basename(package_dir))
)
def _rm_if_it_exists(directory): def _rm_if_it_exists(directory):
if os.path.isdir(directory): if os.path.isdir(directory):
@ -74,6 +74,7 @@ def cleanup(package_dir):
_rm_if_it_exists(dist_dir) _rm_if_it_exists(dist_dir)
_rm_if_it_exists(egg_info) _rm_if_it_exists(egg_info)
def set_variable(fp, variable, value): def set_variable(fp, variable, value):
fh, temp_abs_path = tempfile.mkstemp() fh, temp_abs_path = tempfile.mkstemp()
with os.fdopen(fh, 'w') as new_file, open(fp) as old_file: with os.fdopen(fh, 'w') as new_file, open(fp) as old_file:
@ -90,10 +91,10 @@ def set_variable(fp, variable, value):
os.remove(fp) os.remove(fp)
shutil.move(temp_abs_path, fp) shutil.move(temp_abs_path, fp)
message = 'Set {variable} in {fp} to {value}.'.format( message = 'Set {variable} in {fp} to {value}.'.format(fp=fp, variable=variable, value=value)
fp=fp, variable=variable, value=value)
print(crayons.white(message, bold=True)) print(crayons.white(message, bold=True))
def update_requirements_in_zulip_repo(zulip_repo_dir, version, hash_or_tag): def update_requirements_in_zulip_repo(zulip_repo_dir, version, hash_or_tag):
common = os.path.join(zulip_repo_dir, 'requirements', 'common.in') common = os.path.join(zulip_repo_dir, 'requirements', 'common.in')
prod = os.path.join(zulip_repo_dir, 'requirements', 'prod.txt') prod = os.path.join(zulip_repo_dir, 'requirements', 'prod.txt')
@ -115,10 +116,8 @@ def update_requirements_in_zulip_repo(zulip_repo_dir, version, hash_or_tag):
url_zulip = 'git+https://github.com/zulip/python-zulip-api.git@{tag}#egg={name}=={version}_git&subdirectory={name}\n' url_zulip = 'git+https://github.com/zulip/python-zulip-api.git@{tag}#egg={name}=={version}_git&subdirectory={name}\n'
url_zulip_bots = 'git+https://github.com/zulip/python-zulip-api.git@{tag}#egg={name}=={version}+git&subdirectory={name}\n' url_zulip_bots = 'git+https://github.com/zulip/python-zulip-api.git@{tag}#egg={name}=={version}+git&subdirectory={name}\n'
zulip_bots_line = url_zulip_bots.format(tag=hash_or_tag, name='zulip_bots', zulip_bots_line = url_zulip_bots.format(tag=hash_or_tag, name='zulip_bots', version=version)
version=version) zulip_line = url_zulip.format(tag=hash_or_tag, name='zulip', version=version)
zulip_line = url_zulip.format(tag=hash_or_tag, name='zulip',
version=version)
_edit_reqs_file(prod, zulip_bots_line, zulip_line) _edit_reqs_file(prod, zulip_bots_line, zulip_line)
_edit_reqs_file(dev, zulip_bots_line, zulip_line) _edit_reqs_file(dev, zulip_bots_line, zulip_line)
@ -135,6 +134,7 @@ def update_requirements_in_zulip_repo(zulip_repo_dir, version, hash_or_tag):
message = 'Updated zulip API package requirements in the main repo.' message = 'Updated zulip API package requirements in the main repo.'
print(crayons.white(message, bold=True)) print(crayons.white(message, bold=True))
def parse_args(): def parse_args():
usage = """ usage = """
Script to automate the PyPA release of the zulip, zulip_bots and Script to automate the PyPA release of the zulip, zulip_bots and
@ -176,26 +176,36 @@ And you're done! Congrats!
""" """
parser = argparse.ArgumentParser(usage=usage) parser = argparse.ArgumentParser(usage=usage)
parser.add_argument('--cleanup', '-c', parser.add_argument(
action='store_true', '--cleanup',
default=False, '-c',
help='Remove build directories (dist/, build/, egg-info/, etc).') action='store_true',
default=False,
help='Remove build directories (dist/, build/, egg-info/, etc).',
)
parser.add_argument('--build', '-b', parser.add_argument(
metavar='VERSION_NUM', '--build',
help=('Build sdists and wheels for all packages with the' '-b',
'specified version number.' metavar='VERSION_NUM',
' sdists and wheels are stored in <package_name>/dist/*.')) help=(
'Build sdists and wheels for all packages with the'
'specified version number.'
' sdists and wheels are stored in <package_name>/dist/*.'
),
)
parser.add_argument('--release', '-r', parser.add_argument(
action='store_true', '--release',
default=False, '-r',
help='Upload the packages to PyPA using twine.') action='store_true',
default=False,
help='Upload the packages to PyPA using twine.',
)
subparsers = parser.add_subparsers(dest='subcommand') subparsers = parser.add_subparsers(dest='subcommand')
parser_main_repo = subparsers.add_parser( parser_main_repo = subparsers.add_parser(
'update-main-repo', 'update-main-repo', help='Update the zulip/requirements/* in the main zulip repo.'
help='Update the zulip/requirements/* in the main zulip repo.'
) )
parser_main_repo.add_argument('repo', metavar='PATH_TO_ZULIP_DIR') parser_main_repo.add_argument('repo', metavar='PATH_TO_ZULIP_DIR')
parser_main_repo.add_argument('version', metavar='version number of the packages') parser_main_repo.add_argument('version', metavar='version number of the packages')
@ -203,6 +213,7 @@ And you're done! Congrats!
return parser.parse_args() return parser.parse_args()
def main(): def main():
options = parse_args() options = parse_args()
@ -239,11 +250,10 @@ def main():
if options.subcommand == 'update-main-repo': if options.subcommand == 'update-main-repo':
if options.hash: if options.hash:
update_requirements_in_zulip_repo(options.repo, options.version, update_requirements_in_zulip_repo(options.repo, options.version, options.hash)
options.hash)
else: else:
update_requirements_in_zulip_repo(options.repo, options.version, update_requirements_in_zulip_repo(options.repo, options.version, options.version)
options.version)
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View file

@ -9,24 +9,29 @@ def exit(message: str) -> None:
print(message) print(message)
sys.exit(1) sys.exit(1)
def run(command: str) -> None: def run(command: str) -> None:
print('\n>>> ' + command) print('\n>>> ' + command)
subprocess.check_call(command.split()) subprocess.check_call(command.split())
def check_output(command: str) -> str: def check_output(command: str) -> str:
return subprocess.check_output(command.split()).decode('ascii') return subprocess.check_output(command.split()).decode('ascii')
def get_git_branch() -> str: def get_git_branch() -> str:
command = 'git rev-parse --abbrev-ref HEAD' command = 'git rev-parse --abbrev-ref HEAD'
output = check_output(command) output = check_output(command)
return output.strip() return output.strip()
def check_git_pristine() -> None: def check_git_pristine() -> None:
command = 'git status --porcelain' command = 'git status --porcelain'
output = check_output(command) output = check_output(command)
if output.strip(): if output.strip():
exit('Git is not pristine:\n' + output) exit('Git is not pristine:\n' + output)
def ensure_on_clean_master() -> None: def ensure_on_clean_master() -> None:
branch = get_git_branch() branch = get_git_branch()
if branch != 'master': if branch != 'master':
@ -35,6 +40,7 @@ def ensure_on_clean_master() -> None:
run('git fetch upstream master') run('git fetch upstream master')
run('git rebase upstream/master') run('git rebase upstream/master')
def create_pull_branch(pull_id: int) -> None: def create_pull_branch(pull_id: int) -> None:
run('git fetch upstream pull/%d/head' % (pull_id,)) run('git fetch upstream pull/%d/head' % (pull_id,))
run('git checkout -B review-%s FETCH_HEAD' % (pull_id,)) run('git checkout -B review-%s FETCH_HEAD' % (pull_id,))
@ -44,8 +50,8 @@ def create_pull_branch(pull_id: int) -> None:
print() print()
print('PR: %d' % (pull_id,)) print('PR: %d' % (pull_id,))
print(subprocess.check_output(['git', 'log', 'HEAD~..', print(subprocess.check_output(['git', 'log', 'HEAD~..', '--pretty=format:Author: %an']))
'--pretty=format:Author: %an']))
def review_pr() -> None: def review_pr() -> None:
try: try:
@ -56,5 +62,6 @@ def review_pr() -> None:
ensure_on_clean_master() ensure_on_clean_master()
create_pull_branch(pull_id) create_pull_branch(pull_id)
if __name__ == '__main__': if __name__ == '__main__':
review_pr() review_pr()

View file

@ -99,38 +99,78 @@ force_include = [
"zulip_bots/zulip_bots/bots/front/front.py", "zulip_bots/zulip_bots/bots/front/front.py",
"zulip_bots/zulip_bots/bots/front/test_front.py", "zulip_bots/zulip_bots/bots/front/test_front.py",
"tools/custom_check.py", "tools/custom_check.py",
"tools/deploy" "tools/deploy",
] ]
parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.") parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.")
parser.add_argument('targets', nargs='*', default=[], parser.add_argument(
help="""files and directories to include in the result. 'targets',
If this is not specified, the current directory is used""") nargs='*',
parser.add_argument('-m', '--modified', action='store_true', default=False, help='list only modified files') default=[],
parser.add_argument('-a', '--all', dest='all', action='store_true', default=False, help="""files and directories to include in the result.
help="""run mypy on all python files, ignoring the exclude list. If this is not specified, the current directory is used""",
This is useful if you have to find out which files fail mypy check.""") )
parser.add_argument('--no-disallow-untyped-defs', dest='disallow_untyped_defs', action='store_false', default=True, parser.add_argument(
help="""Don't throw errors when functions are not annotated""") '-m', '--modified', action='store_true', default=False, help='list only modified files'
parser.add_argument('--scripts-only', dest='scripts_only', action='store_true', default=False, )
help="""Only type check extensionless python scripts""") parser.add_argument(
parser.add_argument('--warn-unused-ignores', dest='warn_unused_ignores', action='store_true', default=False, '-a',
help="""Use the --warn-unused-ignores flag with mypy""") '--all',
parser.add_argument('--no-ignore-missing-imports', dest='ignore_missing_imports', action='store_false', default=True, dest='all',
help="""Don't use the --ignore-missing-imports flag with mypy""") action='store_true',
parser.add_argument('--quick', action='store_true', default=False, default=False,
help="""Use the --quick flag with mypy""") help="""run mypy on all python files, ignoring the exclude list.
This is useful if you have to find out which files fail mypy check.""",
)
parser.add_argument(
'--no-disallow-untyped-defs',
dest='disallow_untyped_defs',
action='store_false',
default=True,
help="""Don't throw errors when functions are not annotated""",
)
parser.add_argument(
'--scripts-only',
dest='scripts_only',
action='store_true',
default=False,
help="""Only type check extensionless python scripts""",
)
parser.add_argument(
'--warn-unused-ignores',
dest='warn_unused_ignores',
action='store_true',
default=False,
help="""Use the --warn-unused-ignores flag with mypy""",
)
parser.add_argument(
'--no-ignore-missing-imports',
dest='ignore_missing_imports',
action='store_false',
default=True,
help="""Don't use the --ignore-missing-imports flag with mypy""",
)
parser.add_argument(
'--quick', action='store_true', default=False, help="""Use the --quick flag with mypy"""
)
args = parser.parse_args() args = parser.parse_args()
if args.all: if args.all:
exclude = [] exclude = []
# find all non-excluded files in current directory # find all non-excluded files in current directory
files_dict = cast(Dict[str, List[str]], files_dict = cast(
lister.list_files(targets=args.targets, ftypes=['py', 'pyi'], Dict[str, List[str]],
use_shebang=True, modified_only=args.modified, lister.list_files(
exclude = exclude + ['stubs'], group_by_ftype=True, targets=args.targets,
extless_only=args.scripts_only)) ftypes=['py', 'pyi'],
use_shebang=True,
modified_only=args.modified,
exclude=exclude + ['stubs'],
group_by_ftype=True,
extless_only=args.scripts_only,
),
)
for inpath in force_include: for inpath in force_include:
try: try:
@ -140,10 +180,13 @@ for inpath in force_include:
files_dict[ext].append(inpath) files_dict[ext].append(inpath)
pyi_files = set(files_dict['pyi']) pyi_files = set(files_dict['pyi'])
python_files = [fpath for fpath in files_dict['py'] python_files = [
if not fpath.endswith('.py') or fpath + 'i' not in pyi_files] fpath for fpath in files_dict['py'] if not fpath.endswith('.py') or fpath + 'i' not in pyi_files
]
repo_python_files = OrderedDict([('zulip', []), ('zulip_bots', []), ('zulip_botserver', []), ('tools', [])]) repo_python_files = OrderedDict(
[('zulip', []), ('zulip_bots', []), ('zulip_botserver', []), ('tools', [])]
)
for file_path in python_files: for file_path in python_files:
repo = PurePath(file_path).parts[0] repo = PurePath(file_path).parts[0]
if repo in repo_python_files: if repo in repo_python_files:

View file

@ -1,4 +1,3 @@
import argparse import argparse
import os import os
import shutil import shutil
@ -10,21 +9,26 @@ import pytest
TOOLS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TOOLS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(os.path.dirname(TOOLS_DIR)) os.chdir(os.path.dirname(TOOLS_DIR))
def handle_input_and_run_tests_for_package(package_name, path_list): def handle_input_and_run_tests_for_package(package_name, path_list):
parser = argparse.ArgumentParser(description="Run tests for {}.".format(package_name)) parser = argparse.ArgumentParser(description="Run tests for {}.".format(package_name))
parser.add_argument('--coverage', parser.add_argument(
nargs='?', '--coverage',
const=True, nargs='?',
default=False, const=True,
help='compute test coverage (--coverage combine to combine with previous reports)') default=False,
parser.add_argument('--pytest', '-p', help='compute test coverage (--coverage combine to combine with previous reports)',
default=False, )
action='store_true', parser.add_argument(
help="run tests with pytest") '--pytest', '-p', default=False, action='store_true', help="run tests with pytest"
parser.add_argument('--verbose', '-v', )
default=False, parser.add_argument(
action='store_true', '--verbose',
help='show verbose output (with pytest)') '-v',
default=False,
action='store_true',
help='show verbose output (with pytest)',
)
options = parser.parse_args() options = parser.parse_args()
test_session_title = ' Running tests for {} '.format(package_name) test_session_title = ' Running tests for {} '.format(package_name)
@ -33,6 +37,7 @@ def handle_input_and_run_tests_for_package(package_name, path_list):
if options.coverage: if options.coverage:
import coverage import coverage
cov = coverage.Coverage(config_file="tools/.coveragerc") cov = coverage.Coverage(config_file="tools/.coveragerc")
if options.coverage == 'combine': if options.coverage == 'combine':
cov.load() cov.load()
@ -42,11 +47,11 @@ def handle_input_and_run_tests_for_package(package_name, path_list):
location_to_run_in = os.path.join(TOOLS_DIR, '..', *path_list) location_to_run_in = os.path.join(TOOLS_DIR, '..', *path_list)
paths_to_test = ['.'] paths_to_test = ['.']
pytest_options = [ pytest_options = [
'-s', # show output from tests; this hides the progress bar though '-s', # show output from tests; this hides the progress bar though
'-x', # stop on first test failure '-x', # stop on first test failure
'--ff', # runs last failure first '--ff', # runs last failure first
] ]
pytest_options += (['-v'] if options.verbose else []) pytest_options += ['-v'] if options.verbose else []
os.chdir(location_to_run_in) os.chdir(location_to_run_in)
result = pytest.main(paths_to_test + pytest_options) result = pytest.main(paths_to_test + pytest_options)
if result != 0: if result != 0:

View file

@ -31,33 +31,37 @@ the tests for xkcd and wikipedia bots):
""" """
parser = argparse.ArgumentParser(description=description) parser = argparse.ArgumentParser(description=description)
parser.add_argument('bots_to_test', parser.add_argument(
metavar='bot', 'bots_to_test',
nargs='*', metavar='bot',
default=[], nargs='*',
help='specific bots to test (default is all)') default=[],
parser.add_argument('--coverage', help='specific bots to test (default is all)',
nargs='?', )
const=True, parser.add_argument(
default=False, '--coverage',
help='compute test coverage (--coverage combine to combine with previous reports)') nargs='?',
parser.add_argument('--exclude', const=True,
metavar='bot', default=False,
nargs='*', help='compute test coverage (--coverage combine to combine with previous reports)',
default=[], )
help='bot(s) to exclude') parser.add_argument('--exclude', metavar='bot', nargs='*', default=[], help='bot(s) to exclude')
parser.add_argument('--error-on-no-init', parser.add_argument(
default=False, '--error-on-no-init',
action="store_true", default=False,
help="whether to exit if a bot has tests which won't run due to no __init__.py") action="store_true",
parser.add_argument('--pytest', '-p', help="whether to exit if a bot has tests which won't run due to no __init__.py",
default=False, )
action='store_true', parser.add_argument(
help="run tests with pytest") '--pytest', '-p', default=False, action='store_true', help="run tests with pytest"
parser.add_argument('--verbose', '-v', )
default=False, parser.add_argument(
action='store_true', '--verbose',
help='show verbose output (with pytest)') '-v',
default=False,
action='store_true',
help='show verbose output (with pytest)',
)
return parser.parse_args() return parser.parse_args()
@ -76,6 +80,7 @@ def main():
if options.coverage: if options.coverage:
import coverage import coverage
cov = coverage.Coverage(config_file="tools/.coveragerc") cov = coverage.Coverage(config_file="tools/.coveragerc")
if options.coverage == 'combine': if options.coverage == 'combine':
cov.load() cov.load()
@ -94,11 +99,11 @@ def main():
excluded_bots = ['merels'] excluded_bots = ['merels']
pytest_bots_to_test = sorted([bot for bot in bots_to_test if bot not in excluded_bots]) pytest_bots_to_test = sorted([bot for bot in bots_to_test if bot not in excluded_bots])
pytest_options = [ pytest_options = [
'-s', # show output from tests; this hides the progress bar though '-s', # show output from tests; this hides the progress bar though
'-x', # stop on first test failure '-x', # stop on first test failure
'--ff', # runs last failure first '--ff', # runs last failure first
] ]
pytest_options += (['-v'] if options.verbose else []) pytest_options += ['-v'] if options.verbose else []
os.chdir(bots_dir) os.chdir(bots_dir)
result = pytest.main(pytest_bots_to_test + pytest_options) result = pytest.main(pytest_bots_to_test + pytest_options)
if result != 0: if result != 0:
@ -116,7 +121,9 @@ def main():
test_suites.append(loader.discover(top_level + name, top_level_dir=top_level)) test_suites.append(loader.discover(top_level + name, top_level_dir=top_level))
except ImportError as exception: except ImportError as exception:
print(exception) print(exception)
print("This likely indicates that you need a '__init__.py' file in your bot directory.") print(
"This likely indicates that you need a '__init__.py' file in your bot directory."
)
if options.error_on_no_init: if options.error_on_no_init:
sys.exit(1) sys.exit(1)
@ -134,5 +141,6 @@ def main():
cov.html_report() cov.html_report()
print("HTML report saved under directory 'htmlcov'.") print("HTML report saved under directory 'htmlcov'.")
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View file

@ -4,11 +4,13 @@ config = {
"api_key": "key1", "api_key": "key1",
"site": "https://realm1.zulipchat.com", "site": "https://realm1.zulipchat.com",
"stream": "bridges", "stream": "bridges",
"subject": "<- realm2"}, "subject": "<- realm2",
},
"bot_2": { "bot_2": {
"email": "tunnel-bot@realm2.zulipchat.com", "email": "tunnel-bot@realm2.zulipchat.com",
"api_key": "key2", "api_key": "key2",
"site": "https://realm2.zulipchat.com", "site": "https://realm2.zulipchat.com",
"stream": "bridges", "stream": "bridges",
"subject": "<- realm1"} "subject": "<- realm1",
},
} }

View file

@ -11,9 +11,9 @@ import interrealm_bridge_config
import zulip import zulip
def create_pipe_event(to_client: zulip.Client, from_bot: Dict[str, Any], def create_pipe_event(
to_bot: Dict[str, Any], stream_wide: bool to_client: zulip.Client, from_bot: Dict[str, Any], to_bot: Dict[str, Any], stream_wide: bool
) -> Callable[[Dict[str, Any]], None]: ) -> Callable[[Dict[str, Any]], None]:
def _pipe_message(msg: Dict[str, Any]) -> None: def _pipe_message(msg: Dict[str, Any]) -> None:
isa_stream = msg["type"] == "stream" isa_stream = msg["type"] == "stream"
not_from_bot = msg["sender_email"] not in (from_bot["email"], to_bot["email"]) not_from_bot = msg["sender_email"] not in (from_bot["email"], to_bot["email"])
@ -32,8 +32,9 @@ def create_pipe_event(to_client: zulip.Client, from_bot: Dict[str, Any],
if "/user_uploads/" in msg["content"]: if "/user_uploads/" in msg["content"]:
# Fix the upload URL of the image to be the source of where it # Fix the upload URL of the image to be the source of where it
# comes from # comes from
msg["content"] = msg["content"].replace("/user_uploads/", msg["content"] = msg["content"].replace(
from_bot["site"] + "/user_uploads/") "/user_uploads/", from_bot["site"] + "/user_uploads/"
)
if msg["content"].startswith(("```", "- ", "* ", "> ", "1. ")): if msg["content"].startswith(("```", "- ", "* ", "> ", "1. ")):
# If a message starts with special prefixes, make sure to prepend a newline for # If a message starts with special prefixes, make sure to prepend a newline for
# formatting purpose # formatting purpose
@ -45,7 +46,7 @@ def create_pipe_event(to_client: zulip.Client, from_bot: Dict[str, Any],
"content": "**{}**: {}".format(msg["sender_full_name"], msg["content"]), "content": "**{}**: {}".format(msg["sender_full_name"], msg["content"]),
"has_attachment": msg.get("has_attachment", False), "has_attachment": msg.get("has_attachment", False),
"has_image": msg.get("has_image", False), "has_image": msg.get("has_image", False),
"has_link": msg.get("has_link", False) "has_link": msg.get("has_link", False),
} }
print(msg_data) print(msg_data)
print(to_client.send_message(msg_data)) print(to_client.send_message(msg_data))
@ -55,8 +56,10 @@ def create_pipe_event(to_client: zulip.Client, from_bot: Dict[str, Any],
if event["type"] == "message": if event["type"] == "message":
msg = event["message"] msg = event["message"]
_pipe_message(msg) _pipe_message(msg)
return _pipe_event return _pipe_event
if __name__ == "__main__": if __name__ == "__main__":
usage = """run-interrealm-bridge [--stream] usage = """run-interrealm-bridge [--stream]
@ -71,20 +74,15 @@ if __name__ == "__main__":
sys.path.append(os.path.join(os.path.dirname(__file__), '..')) sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
parser = argparse.ArgumentParser(usage=usage) parser = argparse.ArgumentParser(usage=usage)
parser.add_argument('--stream', parser.add_argument('--stream', action='store_true', help="", default=False)
action='store_true',
help="",
default=False)
args = parser.parse_args() args = parser.parse_args()
options = interrealm_bridge_config.config options = interrealm_bridge_config.config
bot1 = options["bot_1"] bot1 = options["bot_1"]
bot2 = options["bot_2"] bot2 = options["bot_2"]
client1 = zulip.Client(email=bot1["email"], api_key=bot1["api_key"], client1 = zulip.Client(email=bot1["email"], api_key=bot1["api_key"], site=bot1["site"])
site=bot1["site"]) client2 = zulip.Client(email=bot2["email"], api_key=bot2["api_key"], site=bot2["site"])
client2 = zulip.Client(email=bot2["email"], api_key=bot2["api_key"],
site=bot2["site"])
# A bidirectional tunnel # A bidirectional tunnel
pipe_event1 = create_pipe_event(client2, bot1, bot2, args.stream) pipe_event1 = create_pipe_event(client2, bot1, bot2, args.stream)
p1 = mp.Process(target=client1.call_on_each_event, args=(pipe_event1, ["message"])) p1 = mp.Process(target=client1.call_on_each_event, args=(pipe_event1, ["message"]))

View file

@ -26,7 +26,9 @@ Note that "_zulip" will be automatically appended to the IRC nick provided
""" """
if __name__ == "__main__": if __name__ == "__main__":
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage), allow_provisioning=True) parser = zulip.add_default_arguments(
argparse.ArgumentParser(usage=usage), allow_provisioning=True
)
parser.add_argument('--irc-server', default=None) parser.add_argument('--irc-server', default=None)
parser.add_argument('--port', default=6667) parser.add_argument('--port', default=6667)
parser.add_argument('--nick-prefix', default=None) parser.add_argument('--nick-prefix', default=None)
@ -43,14 +45,24 @@ if __name__ == "__main__":
from irc_mirror_backend import IRCBot from irc_mirror_backend import IRCBot
except ImportError: except ImportError:
traceback.print_exc() traceback.print_exc()
print("You have unsatisfied dependencies. Install all missing dependencies with " print(
"{} --provision".format(sys.argv[0])) "You have unsatisfied dependencies. Install all missing dependencies with "
"{} --provision".format(sys.argv[0])
)
sys.exit(1) sys.exit(1)
if options.irc_server is None or options.nick_prefix is None or options.channel is None: if options.irc_server is None or options.nick_prefix is None or options.channel is None:
parser.error("Missing required argument") parser.error("Missing required argument")
nickname = options.nick_prefix + "_zulip" nickname = options.nick_prefix + "_zulip"
bot = IRCBot(zulip_client, options.stream, options.topic, options.channel, bot = IRCBot(
nickname, options.irc_server, options.nickserv_pw, options.port) zulip_client,
options.stream,
options.topic,
options.channel,
nickname,
options.irc_server,
options.nickserv_pw,
options.port,
)
bot.start() bot.start()

View file

@ -10,8 +10,17 @@ from irc.client_aio import AioReactor
class IRCBot(irc.bot.SingleServerIRCBot): class IRCBot(irc.bot.SingleServerIRCBot):
reactor_class = AioReactor reactor_class = AioReactor
def __init__(self, zulip_client: Any, stream: str, topic: str, channel: irc.bot.Channel, def __init__(
nickname: str, server: str, nickserv_password: str = '', port: int = 6667) -> None: self,
zulip_client: Any,
stream: str,
topic: str,
channel: irc.bot.Channel,
nickname: str,
server: str,
nickserv_password: str = '',
port: int = 6667,
) -> None:
self.channel = channel # type: irc.bot.Channel self.channel = channel # type: irc.bot.Channel
self.zulip_client = zulip_client self.zulip_client = zulip_client
self.stream = stream self.stream = stream
@ -31,9 +40,7 @@ class IRCBot(irc.bot.SingleServerIRCBot):
# Taken from # Taken from
# https://github.com/jaraco/irc/blob/master/irc/client_aio.py, # https://github.com/jaraco/irc/blob/master/irc/client_aio.py,
# in particular the method of AioSimpleIRCClient # in particular the method of AioSimpleIRCClient
self.c = self.reactor.loop.run_until_complete( self.c = self.reactor.loop.run_until_complete(self.connection.connect(*args, **kwargs))
self.connection.connect(*args, **kwargs)
)
print("Listening now. Please send an IRC message to verify operation") print("Listening now. Please send an IRC message to verify operation")
def check_subscription_or_die(self) -> None: def check_subscription_or_die(self) -> None:
@ -43,7 +50,10 @@ class IRCBot(irc.bot.SingleServerIRCBot):
exit(1) exit(1)
subs = [s["name"] for s in resp["subscriptions"]] subs = [s["name"] for s in resp["subscriptions"]]
if self.stream not in subs: if self.stream not in subs:
print("The bot is not yet subscribed to stream '%s'. Please subscribe the bot to the stream first." % (self.stream,)) print(
"The bot is not yet subscribed to stream '%s'. Please subscribe the bot to the stream first."
% (self.stream,)
)
exit(1) exit(1)
def on_nicknameinuse(self, c: ServerConnection, e: Event) -> None: def on_nicknameinuse(self, c: ServerConnection, e: Event) -> None:
@ -70,8 +80,11 @@ class IRCBot(irc.bot.SingleServerIRCBot):
else: else:
return return
else: else:
recipients = [u["short_name"] for u in msg["display_recipient"] if recipients = [
u["email"] != msg["sender_email"]] u["short_name"]
for u in msg["display_recipient"]
if u["email"] != msg["sender_email"]
]
if len(recipients) == 1: if len(recipients) == 1:
send = lambda x: self.c.privmsg(recipients[0], x) send = lambda x: self.c.privmsg(recipients[0], x)
else: else:
@ -89,12 +102,16 @@ class IRCBot(irc.bot.SingleServerIRCBot):
return return
# Forward the PM to Zulip # Forward the PM to Zulip
print(self.zulip_client.send_message({ print(
"sender": sender, self.zulip_client.send_message(
"type": "private", {
"to": "username@example.com", "sender": sender,
"content": content, "type": "private",
})) "to": "username@example.com",
"content": content,
}
)
)
def on_pubmsg(self, c: ServerConnection, e: Event) -> None: def on_pubmsg(self, c: ServerConnection, e: Event) -> None:
content = e.arguments[0] content = e.arguments[0]
@ -103,12 +120,16 @@ class IRCBot(irc.bot.SingleServerIRCBot):
return return
# Forward the stream message to Zulip # Forward the stream message to Zulip
print(self.zulip_client.send_message({ print(
"type": "stream", self.zulip_client.send_message(
"to": self.stream, {
"subject": self.topic, "type": "stream",
"content": "**{}**: {}".format(sender, content), "to": self.stream,
})) "subject": self.topic,
"content": "**{}**: {}".format(sender, content),
}
)
)
def on_dccmsg(self, c: ServerConnection, e: Event) -> None: def on_dccmsg(self, c: ServerConnection, e: Event) -> None:
c.privmsg("You said: " + e.arguments[0]) c.privmsg("You said: " + e.arguments[0])

View file

@ -24,19 +24,22 @@ MATRIX_USERNAME_REGEX = '@([a-zA-Z0-9-_]+):matrix.org'
ZULIP_MESSAGE_TEMPLATE = "**{username}**: {message}" ZULIP_MESSAGE_TEMPLATE = "**{username}**: {message}"
MATRIX_MESSAGE_TEMPLATE = "<{username}> {message}" MATRIX_MESSAGE_TEMPLATE = "<{username}> {message}"
class Bridge_ConfigException(Exception): class Bridge_ConfigException(Exception):
pass pass
class Bridge_FatalMatrixException(Exception): class Bridge_FatalMatrixException(Exception):
pass pass
class Bridge_ZulipFatalException(Exception): class Bridge_ZulipFatalException(Exception):
pass pass
def matrix_login(matrix_client: Any, matrix_config: Dict[str, Any]) -> None: def matrix_login(matrix_client: Any, matrix_config: Dict[str, Any]) -> None:
try: try:
matrix_client.login_with_password(matrix_config["username"], matrix_client.login_with_password(matrix_config["username"], matrix_config["password"])
matrix_config["password"])
except MatrixRequestError as exception: except MatrixRequestError as exception:
if exception.code == 403: if exception.code == 403:
raise Bridge_FatalMatrixException("Bad username or password.") raise Bridge_FatalMatrixException("Bad username or password.")
@ -45,6 +48,7 @@ def matrix_login(matrix_client: Any, matrix_config: Dict[str, Any]) -> None:
except MissingSchema: except MissingSchema:
raise Bridge_FatalMatrixException("Bad URL format.") raise Bridge_FatalMatrixException("Bad URL format.")
def matrix_join_room(matrix_client: Any, matrix_config: Dict[str, Any]) -> Any: def matrix_join_room(matrix_client: Any, matrix_config: Dict[str, Any]) -> Any:
try: try:
room = matrix_client.join_room(matrix_config["room_id"]) room = matrix_client.join_room(matrix_config["room_id"])
@ -55,10 +59,12 @@ def matrix_join_room(matrix_client: Any, matrix_config: Dict[str, Any]) -> Any:
else: else:
raise Bridge_FatalMatrixException("Couldn't find room.") raise Bridge_FatalMatrixException("Couldn't find room.")
def die(signal: int, frame: FrameType) -> None: def die(signal: int, frame: FrameType) -> None:
# We actually want to exit, so run os._exit (so as not to be caught and restarted) # We actually want to exit, so run os._exit (so as not to be caught and restarted)
os._exit(1) os._exit(1)
def matrix_to_zulip( def matrix_to_zulip(
zulip_client: zulip.Client, zulip_client: zulip.Client,
zulip_config: Dict[str, Any], zulip_config: Dict[str, Any],
@ -78,12 +84,14 @@ def matrix_to_zulip(
if not_from_zulip_bot and content: if not_from_zulip_bot and content:
try: try:
result = zulip_client.send_message({ result = zulip_client.send_message(
"type": "stream", {
"to": zulip_config["stream"], "type": "stream",
"subject": zulip_config["topic"], "to": zulip_config["stream"],
"content": content, "subject": zulip_config["topic"],
}) "content": content,
}
)
except Exception as exception: # XXX This should be more specific except Exception as exception: # XXX This should be more specific
# Generally raised when user is forbidden # Generally raised when user is forbidden
raise Bridge_ZulipFatalException(exception) raise Bridge_ZulipFatalException(exception)
@ -93,6 +101,7 @@ def matrix_to_zulip(
return _matrix_to_zulip return _matrix_to_zulip
def get_message_content_from_event(event: Dict[str, Any], no_noise: bool) -> Optional[str]: def get_message_content_from_event(event: Dict[str, Any], no_noise: bool) -> Optional[str]:
irc_nick = shorten_irc_nick(event['sender']) irc_nick = shorten_irc_nick(event['sender'])
if event['type'] == "m.room.member": if event['type'] == "m.room.member":
@ -101,19 +110,19 @@ def get_message_content_from_event(event: Dict[str, Any], no_noise: bool) -> Opt
# Join and leave events can be noisy. They are ignored by default. # Join and leave events can be noisy. They are ignored by default.
# To enable these events pass `no_noise` as `False` as the script argument # To enable these events pass `no_noise` as `False` as the script argument
if event['membership'] == "join": if event['membership'] == "join":
content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, message="joined")
message="joined")
elif event['membership'] == "leave": elif event['membership'] == "leave":
content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, message="quit")
message="quit")
elif event['type'] == "m.room.message": elif event['type'] == "m.room.message":
if event['content']['msgtype'] == "m.text" or event['content']['msgtype'] == "m.emote": if event['content']['msgtype'] == "m.text" or event['content']['msgtype'] == "m.emote":
content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, content = ZULIP_MESSAGE_TEMPLATE.format(
message=event['content']['body']) username=irc_nick, message=event['content']['body']
)
else: else:
content = event['type'] content = event['type']
return content return content
def shorten_irc_nick(nick: str) -> str: def shorten_irc_nick(nick: str) -> str:
""" """
Add nick shortner functions for specific IRC networks Add nick shortner functions for specific IRC networks
@ -130,8 +139,8 @@ def shorten_irc_nick(nick: str) -> str:
return match.group(1) return match.group(1)
return nick return nick
def zulip_to_matrix(config: Dict[str, Any], room: Any) -> Callable[[Dict[str, Any]], None]:
def zulip_to_matrix(config: Dict[str, Any], room: Any) -> Callable[[Dict[str, Any]], None]:
def _zulip_to_matrix(msg: Dict[str, Any]) -> None: def _zulip_to_matrix(msg: Dict[str, Any]) -> None:
""" """
Zulip -> Matrix Zulip -> Matrix
@ -139,12 +148,15 @@ def zulip_to_matrix(config: Dict[str, Any], room: Any) -> Callable[[Dict[str, An
message_valid = check_zulip_message_validity(msg, config) message_valid = check_zulip_message_validity(msg, config)
if message_valid: if message_valid:
matrix_username = msg["sender_full_name"].replace(' ', '') matrix_username = msg["sender_full_name"].replace(' ', '')
matrix_text = MATRIX_MESSAGE_TEMPLATE.format(username=matrix_username, matrix_text = MATRIX_MESSAGE_TEMPLATE.format(
message=msg["content"]) username=matrix_username, message=msg["content"]
)
# Forward Zulip message to Matrix # Forward Zulip message to Matrix
room.send_text(matrix_text) room.send_text(matrix_text)
return _zulip_to_matrix return _zulip_to_matrix
def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> bool: def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> bool:
is_a_stream = msg["type"] == "stream" is_a_stream = msg["type"] == "stream"
in_the_specified_stream = msg["display_recipient"] == config["stream"] in_the_specified_stream = msg["display_recipient"] == config["stream"]
@ -157,6 +169,7 @@ def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) ->
return True return True
return False return False
def generate_parser() -> argparse.ArgumentParser: def generate_parser() -> argparse.ArgumentParser:
description = """ description = """
Script to bridge between a topic in a Zulip stream, and a Matrix channel. Script to bridge between a topic in a Zulip stream, and a Matrix channel.
@ -169,19 +182,34 @@ def generate_parser() -> argparse.ArgumentParser:
* #zulip:matrix.org (zulip channel on Matrix) * #zulip:matrix.org (zulip channel on Matrix)
* #freenode_#zulip:matrix.org (zulip channel on irc.freenode.net)""" * #freenode_#zulip:matrix.org (zulip channel on irc.freenode.net)"""
parser = argparse.ArgumentParser(description=description, parser = argparse.ArgumentParser(
formatter_class=argparse.RawTextHelpFormatter) description=description, formatter_class=argparse.RawTextHelpFormatter
parser.add_argument('-c', '--config', required=False, )
help="Path to the config file for the bridge.") parser.add_argument(
parser.add_argument('--write-sample-config', metavar='PATH', dest='sample_config', '-c', '--config', required=False, help="Path to the config file for the bridge."
help="Generate a configuration template at the specified location.") )
parser.add_argument('--from-zuliprc', metavar='ZULIPRC', dest='zuliprc', parser.add_argument(
help="Optional path to zuliprc file for bot, when using --write-sample-config") '--write-sample-config',
parser.add_argument('--show-join-leave', dest='no_noise', metavar='PATH',
default=True, action='store_false', dest='sample_config',
help="Enable IRC join/leave events.") help="Generate a configuration template at the specified location.",
)
parser.add_argument(
'--from-zuliprc',
metavar='ZULIPRC',
dest='zuliprc',
help="Optional path to zuliprc file for bot, when using --write-sample-config",
)
parser.add_argument(
'--show-join-leave',
dest='no_noise',
default=True,
action='store_false',
help="Enable IRC join/leave events.",
)
return parser return parser
def read_configuration(config_file: str) -> Dict[str, Dict[str, str]]: def read_configuration(config_file: str) -> Dict[str, Dict[str, str]]:
config = configparser.ConfigParser() config = configparser.ConfigParser()
@ -197,25 +225,40 @@ def read_configuration(config_file: str) -> Dict[str, Dict[str, str]]:
return {section: dict(config[section]) for section in config.sections()} return {section: dict(config[section]) for section in config.sections()}
def write_sample_config(target_path: str, zuliprc: Optional[str]) -> None: def write_sample_config(target_path: str, zuliprc: Optional[str]) -> None:
if os.path.exists(target_path): if os.path.exists(target_path):
raise Bridge_ConfigException("Path '{}' exists; not overwriting existing file.".format(target_path)) raise Bridge_ConfigException(
"Path '{}' exists; not overwriting existing file.".format(target_path)
)
sample_dict = OrderedDict(( sample_dict = OrderedDict(
('matrix', OrderedDict(( (
('host', 'https://matrix.org'), (
('username', 'username'), 'matrix',
('password', 'password'), OrderedDict(
('room_id', '#zulip:matrix.org'), (
))), ('host', 'https://matrix.org'),
('zulip', OrderedDict(( ('username', 'username'),
('email', 'glitch-bot@chat.zulip.org'), ('password', 'password'),
('api_key', 'aPiKeY'), ('room_id', '#zulip:matrix.org'),
('site', 'https://chat.zulip.org'), )
('stream', 'test here'), ),
('topic', 'matrix'), ),
))), (
)) 'zulip',
OrderedDict(
(
('email', 'glitch-bot@chat.zulip.org'),
('api_key', 'aPiKeY'),
('site', 'https://chat.zulip.org'),
('stream', 'test here'),
('topic', 'matrix'),
)
),
),
)
)
if zuliprc is not None: if zuliprc is not None:
if not os.path.exists(zuliprc): if not os.path.exists(zuliprc):
@ -238,6 +281,7 @@ def write_sample_config(target_path: str, zuliprc: Optional[str]) -> None:
with open(target_path, 'w') as target: with open(target_path, 'w') as target:
sample.write(target) sample.write(target)
def main() -> None: def main() -> None:
signal.signal(signal.SIGINT, die) signal.signal(signal.SIGINT, die)
logging.basicConfig(level=logging.WARNING) logging.basicConfig(level=logging.WARNING)
@ -254,8 +298,11 @@ def main() -> None:
if options.zuliprc is None: if options.zuliprc is None:
print("Wrote sample configuration to '{}'".format(options.sample_config)) print("Wrote sample configuration to '{}'".format(options.sample_config))
else: else:
print("Wrote sample configuration to '{}' using zuliprc file '{}'" print(
.format(options.sample_config, options.zuliprc)) "Wrote sample configuration to '{}' using zuliprc file '{}'".format(
options.sample_config, options.zuliprc
)
)
sys.exit(0) sys.exit(0)
elif not options.config: elif not options.config:
print("Options required: -c or --config to run, OR --write-sample-config.") print("Options required: -c or --config to run, OR --write-sample-config.")
@ -277,9 +324,11 @@ def main() -> None:
while backoff.keep_going(): while backoff.keep_going():
print("Starting matrix mirroring bot") print("Starting matrix mirroring bot")
try: try:
zulip_client = zulip.Client(email=zulip_config["email"], zulip_client = zulip.Client(
api_key=zulip_config["api_key"], email=zulip_config["email"],
site=zulip_config["site"]) api_key=zulip_config["api_key"],
site=zulip_config["site"],
)
matrix_client = MatrixClient(matrix_config["host"]) matrix_client = MatrixClient(matrix_config["host"])
# Login to Matrix # Login to Matrix
@ -287,8 +336,9 @@ def main() -> None:
# Join a room in Matrix # Join a room in Matrix
room = matrix_join_room(matrix_client, matrix_config) room = matrix_join_room(matrix_client, matrix_config)
room.add_listener(matrix_to_zulip(zulip_client, zulip_config, matrix_config, room.add_listener(
options.no_noise)) matrix_to_zulip(zulip_client, zulip_config, matrix_config, options.no_noise)
)
print("Starting listener thread on Matrix client") print("Starting listener thread on Matrix client")
matrix_client.start_listener_thread() matrix_client.start_listener_thread()
@ -306,5 +356,6 @@ def main() -> None:
traceback.print_exc() traceback.print_exc()
backoff.fail() backoff.fail()
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View file

@ -30,22 +30,26 @@ topic = matrix
""" """
@contextmanager @contextmanager
def new_temp_dir() -> Iterator[str]: def new_temp_dir() -> Iterator[str]:
path = mkdtemp() path = mkdtemp()
yield path yield path
shutil.rmtree(path) shutil.rmtree(path)
class MatrixBridgeScriptTests(TestCase): class MatrixBridgeScriptTests(TestCase):
def output_from_script(self, options: List[str]) -> List[str]: def output_from_script(self, options: List[str]) -> List[str]:
popen = Popen(["python", script] + options, stdin=PIPE, stdout=PIPE, universal_newlines=True) popen = Popen(
["python", script] + options, stdin=PIPE, stdout=PIPE, universal_newlines=True
)
return popen.communicate()[0].strip().split("\n") return popen.communicate()[0].strip().split("\n")
def test_no_args(self) -> None: def test_no_args(self) -> None:
output_lines = self.output_from_script([]) output_lines = self.output_from_script([])
expected_lines = [ expected_lines = [
"Options required: -c or --config to run, OR --write-sample-config.", "Options required: -c or --config to run, OR --write-sample-config.",
"usage: {} [-h]".format(script_file) "usage: {} [-h]".format(script_file),
] ]
for expected, output in zip(expected_lines, output_lines): for expected, output in zip(expected_lines, output_lines):
self.assertIn(expected, output) self.assertIn(expected, output)
@ -74,19 +78,27 @@ class MatrixBridgeScriptTests(TestCase):
def test_write_sample_config_from_zuliprc(self) -> None: def test_write_sample_config_from_zuliprc(self) -> None:
zuliprc_template = ["[api]", "email={email}", "key={key}", "site={site}"] zuliprc_template = ["[api]", "email={email}", "key={key}", "site={site}"]
zulip_params = {'email': 'foo@bar', zulip_params = {
'key': 'some_api_key', 'email': 'foo@bar',
'site': 'https://some.chat.serverplace'} 'key': 'some_api_key',
'site': 'https://some.chat.serverplace',
}
with new_temp_dir() as tempdir: with new_temp_dir() as tempdir:
path = os.path.join(tempdir, sample_config_path) path = os.path.join(tempdir, sample_config_path)
zuliprc_path = os.path.join(tempdir, "zuliprc") zuliprc_path = os.path.join(tempdir, "zuliprc")
with open(zuliprc_path, "w") as zuliprc_file: with open(zuliprc_path, "w") as zuliprc_file:
zuliprc_file.write("\n".join(zuliprc_template).format(**zulip_params)) zuliprc_file.write("\n".join(zuliprc_template).format(**zulip_params))
output_lines = self.output_from_script(["--write-sample-config", path, output_lines = self.output_from_script(
"--from-zuliprc", zuliprc_path]) ["--write-sample-config", path, "--from-zuliprc", zuliprc_path]
self.assertEqual(output_lines, )
["Wrote sample configuration to '{}' using zuliprc file '{}'" self.assertEqual(
.format(path, zuliprc_path)]) output_lines,
[
"Wrote sample configuration to '{}' using zuliprc file '{}'".format(
path, zuliprc_path
)
],
)
with open(path) as sample_file: with open(path) as sample_file:
sample_lines = [line.strip() for line in sample_file.readlines()] sample_lines = [line.strip() for line in sample_file.readlines()]
@ -101,23 +113,26 @@ class MatrixBridgeScriptTests(TestCase):
path = os.path.join(tempdir, sample_config_path) path = os.path.join(tempdir, sample_config_path)
zuliprc_path = os.path.join(tempdir, "zuliprc") zuliprc_path = os.path.join(tempdir, "zuliprc")
# No writing of zuliprc file here -> triggers check for zuliprc absence # No writing of zuliprc file here -> triggers check for zuliprc absence
output_lines = self.output_from_script(["--write-sample-config", path, output_lines = self.output_from_script(
"--from-zuliprc", zuliprc_path]) ["--write-sample-config", path, "--from-zuliprc", zuliprc_path]
self.assertEqual(output_lines, )
["Could not write sample config: Zuliprc file '{}' does not exist." self.assertEqual(
.format(zuliprc_path)]) output_lines,
[
"Could not write sample config: Zuliprc file '{}' does not exist.".format(
zuliprc_path
)
],
)
class MatrixBridgeZulipToMatrixTests(TestCase): class MatrixBridgeZulipToMatrixTests(TestCase):
valid_zulip_config = dict( valid_zulip_config = dict(stream="some stream", topic="some topic", email="some@email")
stream="some stream",
topic="some topic",
email="some@email"
)
valid_msg = dict( valid_msg = dict(
sender_email="John@Smith.smith", # must not be equal to config:email sender_email="John@Smith.smith", # must not be equal to config:email
type="stream", # Can only mirror Zulip streams type="stream", # Can only mirror Zulip streams
display_recipient=valid_zulip_config['stream'], display_recipient=valid_zulip_config['stream'],
subject=valid_zulip_config['topic'] subject=valid_zulip_config['topic'],
) )
def test_zulip_message_validity_success(self) -> None: def test_zulip_message_validity_success(self) -> None:

View file

@ -10,5 +10,5 @@ config = {
"username": "slack username", "username": "slack username",
"token": "slack token", "token": "slack token",
"channel": "C5Z5N7R8A -- must be channel id", "channel": "C5Z5N7R8A -- must be channel id",
} },
} }

View file

@ -18,6 +18,7 @@ import zulip
ZULIP_MESSAGE_TEMPLATE = "**{username}**: {message}" ZULIP_MESSAGE_TEMPLATE = "**{username}**: {message}"
SLACK_MESSAGE_TEMPLATE = "<{username}> {message}" SLACK_MESSAGE_TEMPLATE = "<{username}> {message}"
def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> bool: def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> bool:
is_a_stream = msg["type"] == "stream" is_a_stream = msg["type"] == "stream"
in_the_specified_stream = msg["display_recipient"] == config["stream"] in_the_specified_stream = msg["display_recipient"] == config["stream"]
@ -30,6 +31,7 @@ def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) ->
return True return True
return False return False
class SlackBridge: class SlackBridge:
def __init__(self, config: Dict[str, Any]) -> None: def __init__(self, config: Dict[str, Any]) -> None:
self.config = config self.config = config
@ -40,7 +42,8 @@ class SlackBridge:
self.zulip_client = zulip.Client( self.zulip_client = zulip.Client(
email=self.zulip_config["email"], email=self.zulip_config["email"],
api_key=self.zulip_config["api_key"], api_key=self.zulip_config["api_key"],
site=self.zulip_config["site"]) site=self.zulip_config["site"],
)
self.zulip_stream = self.zulip_config["stream"] self.zulip_stream = self.zulip_config["stream"]
self.zulip_subject = self.zulip_config["topic"] self.zulip_subject = self.zulip_config["topic"]
@ -69,18 +72,22 @@ class SlackBridge:
message_valid = check_zulip_message_validity(msg, self.zulip_config) message_valid = check_zulip_message_validity(msg, self.zulip_config)
if message_valid: if message_valid:
self.wrap_slack_mention_with_bracket(msg) self.wrap_slack_mention_with_bracket(msg)
slack_text = SLACK_MESSAGE_TEMPLATE.format(username=msg["sender_full_name"], slack_text = SLACK_MESSAGE_TEMPLATE.format(
message=msg["content"]) username=msg["sender_full_name"], message=msg["content"]
)
self.slack_webclient.chat_postMessage( self.slack_webclient.chat_postMessage(
channel=self.channel, channel=self.channel,
text=slack_text, text=slack_text,
) )
return _zulip_to_slack return _zulip_to_slack
def run_slack_listener(self) -> None: def run_slack_listener(self) -> None:
members = self.slack_webclient.users_list()['members'] members = self.slack_webclient.users_list()['members']
# See also https://api.slack.com/changelog/2017-09-the-one-about-usernames # See also https://api.slack.com/changelog/2017-09-the-one-about-usernames
self.slack_id_to_name = {u["id"]: u["profile"].get("display_name", u["profile"]["real_name"]) for u in members} self.slack_id_to_name = {
u["id"]: u["profile"].get("display_name", u["profile"]["real_name"]) for u in members
}
self.slack_name_to_id = {v: k for k, v in self.slack_id_to_name.items()} self.slack_name_to_id = {v: k for k, v in self.slack_id_to_name.items()}
@RTMClient.run_on(event='message') @RTMClient.run_on(event='message')
@ -96,14 +103,13 @@ class SlackBridge:
self.replace_slack_id_with_name(msg) self.replace_slack_id_with_name(msg)
content = ZULIP_MESSAGE_TEMPLATE.format(username=user, message=msg['text']) content = ZULIP_MESSAGE_TEMPLATE.format(username=user, message=msg['text'])
msg_data = dict( msg_data = dict(
type="stream", type="stream", to=self.zulip_stream, subject=self.zulip_subject, content=content
to=self.zulip_stream, )
subject=self.zulip_subject,
content=content)
self.zulip_client.send_message(msg_data) self.zulip_client.send_message(msg_data)
self.slack_client.start() self.slack_client.start()
if __name__ == "__main__": if __name__ == "__main__":
usage = """run-slack-bridge usage = """run-slack-bridge
@ -124,7 +130,9 @@ if __name__ == "__main__":
try: try:
sb = SlackBridge(config) sb = SlackBridge(config)
zp = threading.Thread(target=sb.zulip_client.call_on_each_message, args=(sb.zulip_to_slack(),)) zp = threading.Thread(
target=sb.zulip_client.call_on_each_message, args=(sb.zulip_to_slack(),)
)
sp = threading.Thread(target=sb.run_slack_listener, args=()) sp = threading.Thread(target=sb.run_slack_listener, args=())
print("Starting message handler on Zulip client") print("Starting message handler on Zulip client")
zp.start() zp.start()

View file

@ -39,7 +39,8 @@ client = zulip.Client(
email=config.ZULIP_USER, email=config.ZULIP_USER,
site=config.ZULIP_SITE, site=config.ZULIP_SITE,
api_key=config.ZULIP_API_KEY, api_key=config.ZULIP_API_KEY,
client="ZulipCodebase/" + VERSION) client="ZulipCodebase/" + VERSION,
)
user_agent = "Codebase To Zulip Mirroring script (zulip-devel@googlegroups.com)" user_agent = "Codebase To Zulip Mirroring script (zulip-devel@googlegroups.com)"
# find some form of JSON loader/dumper, with a preference order for speed. # find some form of JSON loader/dumper, with a preference order for speed.
@ -52,13 +53,18 @@ while len(json_implementations):
except ImportError: except ImportError:
continue continue
def make_api_call(path: str) -> Optional[List[Dict[str, Any]]]: def make_api_call(path: str) -> Optional[List[Dict[str, Any]]]:
response = requests.get("https://api3.codebasehq.com/%s" % (path,), response = requests.get(
auth=(config.CODEBASE_API_USERNAME, config.CODEBASE_API_KEY), "https://api3.codebasehq.com/%s" % (path,),
params={'raw': 'True'}, auth=(config.CODEBASE_API_USERNAME, config.CODEBASE_API_KEY),
headers = {"User-Agent": user_agent, params={'raw': 'True'},
"Content-Type": "application/json", headers={
"Accept": "application/json"}) "User-Agent": user_agent,
"Content-Type": "application/json",
"Accept": "application/json",
},
)
if response.status_code == 200: if response.status_code == 200:
return json.loads(response.text) return json.loads(response.text)
@ -69,12 +75,16 @@ def make_api_call(path: str) -> Optional[List[Dict[str, Any]]]:
logging.error("Bad authorization from Codebase. Please check your credentials") logging.error("Bad authorization from Codebase. Please check your credentials")
sys.exit(-1) sys.exit(-1)
else: else:
logging.warn("Found non-success response status code: %s %s" % (response.status_code, response.text)) logging.warn(
"Found non-success response status code: %s %s" % (response.status_code, response.text)
)
return None return None
def make_url(path: str) -> str: def make_url(path: str) -> str:
return "%s/%s" % (config.CODEBASE_ROOT_URL, path) return "%s/%s" % (config.CODEBASE_ROOT_URL, path)
def handle_event(event: Dict[str, Any]) -> None: def handle_event(event: Dict[str, Any]) -> None:
event = event['event'] event = event['event']
event_type = event['type'] event_type = event['type']
@ -114,11 +124,17 @@ def handle_event(event: Dict[str, Any]) -> None:
else: else:
if new_ref: if new_ref:
branch = "new branch %s" % (branch,) branch = "new branch %s" % (branch,)
content = ("%s pushed %s commit(s) to %s in project %s:\n\n" % content = "%s pushed %s commit(s) to %s in project %s:\n\n" % (
(actor_name, num_commits, branch, project)) actor_name,
num_commits,
branch,
project,
)
for commit in raw_props.get('commits'): for commit in raw_props.get('commits'):
ref = commit.get('ref') ref = commit.get('ref')
url = make_url("projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, ref)) url = make_url(
"projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, ref)
)
message = commit.get('message') message = commit.get('message')
content += "* [%s](%s): %s\n" % (ref, url, message) content += "* [%s](%s): %s\n" % (ref, url, message)
elif event_type == 'ticketing_ticket': elif event_type == 'ticketing_ticket':
@ -133,8 +149,10 @@ def handle_event(event: Dict[str, Any]) -> None:
if assignee is None: if assignee is None:
assignee = "no one" assignee = "no one"
subject = "#%s: %s" % (num, name) subject = "#%s: %s" % (num, name)
content = ("""%s created a new ticket [#%s](%s) priority **%s** assigned to %s:\n\n~~~ quote\n %s""" % content = (
(actor_name, num, url, priority, assignee, name)) """%s created a new ticket [#%s](%s) priority **%s** assigned to %s:\n\n~~~ quote\n %s"""
% (actor_name, num, url, priority, assignee, name)
)
elif event_type == 'ticketing_note': elif event_type == 'ticketing_note':
stream = config.ZULIP_TICKETS_STREAM_NAME stream = config.ZULIP_TICKETS_STREAM_NAME
@ -148,11 +166,19 @@ def handle_event(event: Dict[str, Any]) -> None:
content = "" content = ""
if body is not None and len(body) > 0: if body is not None and len(body) > 0:
content = "%s added a comment to ticket [#%s](%s):\n\n~~~ quote\n%s\n\n" % (actor_name, num, url, body) content = "%s added a comment to ticket [#%s](%s):\n\n~~~ quote\n%s\n\n" % (
actor_name,
num,
url,
body,
)
if 'status_id' in changes: if 'status_id' in changes:
status_change = changes.get('status_id') status_change = changes.get('status_id')
content += "Status changed from **%s** to **%s**\n\n" % (status_change[0], status_change[1]) content += "Status changed from **%s** to **%s**\n\n" % (
status_change[0],
status_change[1],
)
elif event_type == 'ticketing_milestone': elif event_type == 'ticketing_milestone':
stream = config.ZULIP_TICKETS_STREAM_NAME stream = config.ZULIP_TICKETS_STREAM_NAME
@ -172,10 +198,17 @@ def handle_event(event: Dict[str, Any]) -> None:
if commit: if commit:
repo_link = raw_props.get('repository_permalink') repo_link = raw_props.get('repository_permalink')
url = make_url('projects/%s/repositories/%s/commit/%s' % (project_link, repo_link, commit)) url = make_url(
'projects/%s/repositories/%s/commit/%s' % (project_link, repo_link, commit)
)
subject = "%s commented on %s" % (actor_name, commit) subject = "%s commented on %s" % (actor_name, commit)
content = "%s commented on [%s](%s):\n\n~~~ quote\n%s" % (actor_name, commit, url, comment) content = "%s commented on [%s](%s):\n\n~~~ quote\n%s" % (
actor_name,
commit,
url,
comment,
)
else: else:
# Otherwise, this is a Discussion item, and handle it # Otherwise, this is a Discussion item, and handle it
subj = raw_props.get("subject") subj = raw_props.get("subject")
@ -199,17 +232,32 @@ def handle_event(event: Dict[str, Any]) -> None:
servers = raw_props.get('servers') servers = raw_props.get('servers')
repo_link = raw_props.get('repository_permalink') repo_link = raw_props.get('repository_permalink')
start_ref_url = make_url("projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, start_ref)) start_ref_url = make_url(
end_ref_url = make_url("projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, end_ref)) "projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, start_ref)
between_url = make_url("projects/%s/repositories/%s/compare/%s...%s" % ( )
project_link, repo_link, start_ref, end_ref)) end_ref_url = make_url(
"projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, end_ref)
)
between_url = make_url(
"projects/%s/repositories/%s/compare/%s...%s"
% (project_link, repo_link, start_ref, end_ref)
)
subject = "Deployment to %s" % (environment,) subject = "Deployment to %s" % (environment,)
content = ("%s deployed [%s](%s) [through](%s) [%s](%s) to the **%s** environment." % content = "%s deployed [%s](%s) [through](%s) [%s](%s) to the **%s** environment." % (
(actor_name, start_ref, start_ref_url, between_url, end_ref, end_ref_url, environment)) actor_name,
start_ref,
start_ref_url,
between_url,
end_ref,
end_ref_url,
environment,
)
if servers is not None: if servers is not None:
content += "\n\nServers deployed to: %s" % (", ".join(["`%s`" % (server,) for server in servers])) content += "\n\nServers deployed to: %s" % (
", ".join(["`%s`" % (server,) for server in servers])
)
elif event_type == 'named_tree': elif event_type == 'named_tree':
# Docs say named_tree type used for new/deleting branches and tags, # Docs say named_tree type used for new/deleting branches and tags,
@ -228,10 +276,9 @@ def handle_event(event: Dict[str, Any]) -> None:
if len(subject) > 60: if len(subject) > 60:
subject = subject[:57].rstrip() + '...' subject = subject[:57].rstrip() + '...'
res = client.send_message({"type": "stream", res = client.send_message(
"to": stream, {"type": "stream", "to": stream, "subject": subject, "content": content}
"subject": subject, )
"content": content})
if res['result'] == 'success': if res['result'] == 'success':
logging.info("Successfully sent Zulip with id: %s" % (res['id'],)) logging.info("Successfully sent Zulip with id: %s" % (res['id'],))
else: else:
@ -278,6 +325,7 @@ def run_mirror() -> None:
open(config.RESUME_FILE, 'w').write(since.strftime("%s")) open(config.RESUME_FILE, 'w').write(since.strftime("%s"))
logging.info("Shutting down Codebase mirror") logging.info("Shutting down Codebase mirror")
# void function that checks the permissions of the files this script needs. # void function that checks the permissions of the files this script needs.
def check_permissions() -> None: def check_permissions() -> None:
# check that the log file can be written # check that the log file can be written
@ -291,9 +339,12 @@ def check_permissions() -> None:
try: try:
open(config.RESUME_FILE, "a+") open(config.RESUME_FILE, "a+")
except OSError as e: except OSError as e:
sys.stderr.write("Could not open up the file %s for reading and writing" % (config.RESUME_FILE,)) sys.stderr.write(
"Could not open up the file %s for reading and writing" % (config.RESUME_FILE,)
)
sys.stderr.write(str(e)) sys.stderr.write(str(e))
if __name__ == "__main__": if __name__ == "__main__":
assert isinstance(config.RESUME_FILE, str), "RESUME_FILE path not given; refusing to continue" assert isinstance(config.RESUME_FILE, str), "RESUME_FILE path not given; refusing to continue"
check_permissions() check_permissions()

View file

@ -29,30 +29,33 @@ client = zulip.Client(
email=config.ZULIP_USER, email=config.ZULIP_USER,
site=config.ZULIP_SITE, site=config.ZULIP_SITE,
api_key=config.ZULIP_API_KEY, api_key=config.ZULIP_API_KEY,
client="ZulipGit/" + VERSION) client="ZulipGit/" + VERSION,
)
def git_repository_name() -> Text: def git_repository_name() -> Text:
output = subprocess.check_output(["git", "rev-parse", "--is-bare-repository"]) output = subprocess.check_output(["git", "rev-parse", "--is-bare-repository"])
if output.strip() == "true": if output.strip() == "true":
return os.path.basename(os.getcwd())[:-len(".git")] return os.path.basename(os.getcwd())[: -len(".git")]
else: else:
return os.path.basename(os.path.dirname(os.getcwd())) return os.path.basename(os.path.dirname(os.getcwd()))
def git_commit_range(oldrev: str, newrev: str) -> str: def git_commit_range(oldrev: str, newrev: str) -> str:
log_cmd = ["git", "log", "--reverse", log_cmd = ["git", "log", "--reverse", "--pretty=%aE %H %s", "%s..%s" % (oldrev, newrev)]
"--pretty=%aE %H %s", "%s..%s" % (oldrev, newrev)]
commits = '' commits = ''
for ln in subprocess.check_output(log_cmd, universal_newlines=True).splitlines(): for ln in subprocess.check_output(log_cmd, universal_newlines=True).splitlines():
author_email, commit_id, subject = ln.split(None, 2) author_email, commit_id, subject = ln.split(None, 2)
if hasattr(config, "format_commit_message"): if hasattr(config, "format_commit_message"):
commits += config.format_commit_message(author_email, subject, commit_id) commits += config.format_commit_message(author_email, subject, commit_id)
else: else:
commits += '!avatar(%s) %s\n' % (author_email, subject) commits += '!avatar(%s) %s\n' % (author_email, subject)
return commits return commits
def send_bot_message(oldrev: str, newrev: str, refname: str) -> None: def send_bot_message(oldrev: str, newrev: str, refname: str) -> None:
repo_name = git_repository_name() repo_name = git_repository_name()
branch = refname.replace('refs/heads/', '') branch = refname.replace('refs/heads/', '')
destination = config.commit_notice_destination(repo_name, branch, newrev) destination = config.commit_notice_destination(repo_name, branch, newrev)
if destination is None: if destination is None:
# Don't forward the notice anywhere # Don't forward the notice anywhere
@ -69,7 +72,7 @@ def send_bot_message(oldrev: str, newrev: str, refname: str) -> None:
added = '' added = ''
removed = '' removed = ''
else: else:
added = git_commit_range(oldrev, newrev) added = git_commit_range(oldrev, newrev)
removed = git_commit_range(newrev, oldrev) removed = git_commit_range(newrev, oldrev)
if oldrev == '0000000000000000000000000000000000000000': if oldrev == '0000000000000000000000000000000000000000':
@ -95,6 +98,7 @@ def send_bot_message(oldrev: str, newrev: str, refname: str) -> None:
} }
client.send_message(message_data) client.send_message(message_data)
for ln in sys.stdin: for ln in sys.stdin:
oldrev, newrev, refname = ln.strip().split() oldrev, newrev, refname = ln.strip().split()
send_bot_message(oldrev, newrev, refname) send_bot_message(oldrev, newrev, refname)

View file

@ -25,12 +25,12 @@ ZULIP_API_KEY = "0123456789abcdef0123456789abcdef"
# And similarly for branch "test-post-receive" (for use when testing). # And similarly for branch "test-post-receive" (for use when testing).
def commit_notice_destination(repo: Text, branch: Text, commit: Text) -> Optional[Dict[Text, Text]]: def commit_notice_destination(repo: Text, branch: Text, commit: Text) -> Optional[Dict[Text, Text]]:
if branch in ["master", "test-post-receive"]: if branch in ["master", "test-post-receive"]:
return dict(stream = STREAM_NAME, return dict(stream=STREAM_NAME, subject="%s" % (branch,))
subject = "%s" % (branch,))
# Return None for cases where you don't want a notice sent # Return None for cases where you don't want a notice sent
return None return None
# Modify this function to change how commits are displayed; the most # Modify this function to change how commits are displayed; the most
# common customization is to include a link to the commit in your # common customization is to include a link to the commit in your
# graphical repository viewer, e.g. # graphical repository viewer, e.g.
@ -39,6 +39,7 @@ def commit_notice_destination(repo: Text, branch: Text, commit: Text) -> Optiona
def format_commit_message(author: Text, subject: Text, commit_id: Text) -> Text: def format_commit_message(author: Text, subject: Text, commit_id: Text) -> Text:
return '!avatar(%s) %s\n' % (author, subject) return '!avatar(%s) %s\n' % (author, subject)
## If properly installed, the Zulip API should be in your import ## If properly installed, the Zulip API should be in your import
## path, but if not, set a custom path below ## path, but if not, set a custom path below
ZULIP_API_PATH: Optional[str] = None ZULIP_API_PATH: Optional[str] = None

View file

@ -7,7 +7,10 @@ from oauth2client.file import Storage
try: try:
import argparse import argparse
flags = argparse.ArgumentParser(parents=[tools.argparser]).parse_args() # type: Optional[argparse.Namespace]
flags = argparse.ArgumentParser(
parents=[tools.argparser]
).parse_args() # type: Optional[argparse.Namespace]
except ImportError: except ImportError:
flags = None flags = None
@ -22,6 +25,7 @@ CLIENT_SECRET_FILE = 'client_secret.json'
APPLICATION_NAME = 'Zulip Calendar Bot' APPLICATION_NAME = 'Zulip Calendar Bot'
HOME_DIR = os.path.expanduser('~') HOME_DIR = os.path.expanduser('~')
def get_credentials() -> client.Credentials: def get_credentials() -> client.Credentials:
"""Gets valid user credentials from storage. """Gets valid user credentials from storage.
@ -32,8 +36,7 @@ def get_credentials() -> client.Credentials:
Credentials, the obtained credential. Credentials, the obtained credential.
""" """
credential_path = os.path.join(HOME_DIR, credential_path = os.path.join(HOME_DIR, 'google-credentials.json')
'google-credentials.json')
store = Storage(credential_path) store = Storage(credential_path)
credentials = store.get() credentials = store.get()
@ -49,4 +52,5 @@ def get_credentials() -> client.Credentials:
credentials = tools.run(flow, store) credentials = tools.run(flow, store)
print('Storing credentials to ' + credential_path) print('Storing credentials to ' + credential_path)
get_credentials() get_credentials()

View file

@ -38,7 +38,9 @@ sent = set() # type: Set[Tuple[int, datetime.datetime]]
sys.path.append(os.path.dirname(__file__)) sys.path.append(os.path.dirname(__file__))
parser = zulip.add_default_arguments(argparse.ArgumentParser(r""" parser = zulip.add_default_arguments(
argparse.ArgumentParser(
r"""
google-calendar --calendar calendarID@example.calendar.google.com google-calendar --calendar calendarID@example.calendar.google.com
@ -53,23 +55,29 @@ google-calendar --calendar calendarID@example.calendar.google.com
revealed to local users through the command line. revealed to local users through the command line.
Depends on: google-api-python-client Depends on: google-api-python-client
""")) """
)
)
parser.add_argument('--interval', parser.add_argument(
dest='interval', '--interval',
default=30, dest='interval',
type=int, default=30,
action='store', type=int,
help='Minutes before event for reminder [default: 30]', action='store',
metavar='MINUTES') help='Minutes before event for reminder [default: 30]',
metavar='MINUTES',
)
parser.add_argument('--calendar', parser.add_argument(
dest = 'calendarID', '--calendar',
default = 'primary', dest='calendarID',
type = str, default='primary',
action = 'store', type=str,
help = 'Calendar ID for the calendar you want to receive reminders from.') action='store',
help='Calendar ID for the calendar you want to receive reminders from.',
)
options = parser.parse_args() options = parser.parse_args()
@ -78,6 +86,7 @@ if not (options.zulip_email):
zulip_client = zulip.init_from_options(options) zulip_client = zulip.init_from_options(options)
def get_credentials() -> client.Credentials: def get_credentials() -> client.Credentials:
"""Gets valid user credentials from storage. """Gets valid user credentials from storage.
@ -89,8 +98,7 @@ def get_credentials() -> client.Credentials:
Credentials, the obtained credential. Credentials, the obtained credential.
""" """
try: try:
credential_path = os.path.join(HOME_DIR, credential_path = os.path.join(HOME_DIR, 'google-credentials.json')
'google-credentials.json')
store = Storage(credential_path) store = Storage(credential_path)
credentials = store.get() credentials = store.get()
@ -110,8 +118,17 @@ def populate_events() -> Optional[None]:
service = discovery.build('calendar', 'v3', http=creds) service = discovery.build('calendar', 'v3', http=creds)
now = datetime.datetime.now(pytz.utc).isoformat() now = datetime.datetime.now(pytz.utc).isoformat()
feed = service.events().list(calendarId=options.calendarID, timeMin=now, maxResults=5, feed = (
singleEvents=True, orderBy='startTime').execute() service.events()
.list(
calendarId=options.calendarID,
timeMin=now,
maxResults=5,
singleEvents=True,
orderBy='startTime',
)
.execute()
)
events = [] events = []
for event in feed["items"]: for event in feed["items"]:
@ -172,14 +189,13 @@ def send_reminders() -> Optional[None]:
else: else:
message = 'Reminder:\n\n' + '\n'.join('* ' + m for m in messages) message = 'Reminder:\n\n' + '\n'.join('* ' + m for m in messages)
zulip_client.send_message(dict( zulip_client.send_message(
type = 'private', dict(type='private', to=options.zulip_email, sender=options.zulip_email, content=message)
to = options.zulip_email, )
sender = options.zulip_email,
content = message))
sent.update(keys) sent.update(keys)
# Loop forever # Loop forever
for i in itertools.count(): for i in itertools.count():
try: try:

View file

@ -15,7 +15,10 @@ import zulip
VERSION = "0.9" VERSION = "0.9"
def format_summary_line(web_url: str, user: str, base: int, tip: int, branch: str, node: Text) -> Text:
def format_summary_line(
web_url: str, user: str, base: int, tip: int, branch: str, node: Text
) -> Text:
""" """
Format the first line of the message, which contains summary Format the first line of the message, which contains summary
information about the changeset and links to the changelog if a information about the changeset and links to the changelog if a
@ -29,16 +32,18 @@ def format_summary_line(web_url: str, user: str, base: int, tip: int, branch: st
if web_url: if web_url:
shortlog_base_url = web_url.rstrip("/") + "/shortlog/" shortlog_base_url = web_url.rstrip("/") + "/shortlog/"
summary_url = "{shortlog}{tip}?revcount={revcount}".format( summary_url = "{shortlog}{tip}?revcount={revcount}".format(
shortlog=shortlog_base_url, tip=tip - 1, revcount=revcount) shortlog=shortlog_base_url, tip=tip - 1, revcount=revcount
)
formatted_commit_count = "[{revcount} commit{s}]({url})".format( formatted_commit_count = "[{revcount} commit{s}]({url})".format(
revcount=revcount, s=plural, url=summary_url) revcount=revcount, s=plural, url=summary_url
)
else: else:
formatted_commit_count = "{revcount} commit{s}".format( formatted_commit_count = "{revcount} commit{s}".format(revcount=revcount, s=plural)
revcount=revcount, s=plural)
return "**{user}** pushed {commits} to **{branch}** (`{tip}:{node}`):\n\n".format( return "**{user}** pushed {commits} to **{branch}** (`{tip}:{node}`):\n\n".format(
user=user, commits=formatted_commit_count, branch=branch, tip=tip, user=user, commits=formatted_commit_count, branch=branch, tip=tip, node=node[:12]
node=node[:12]) )
def format_commit_lines(web_url: str, repo: repo, base: int, tip: int) -> str: def format_commit_lines(web_url: str, repo: repo, base: int, tip: int) -> str:
""" """
@ -56,8 +61,7 @@ def format_commit_lines(web_url: str, repo: repo, base: int, tip: int) -> str:
if web_url: if web_url:
summary_url = rev_base_url + str(rev_ctx) summary_url = rev_base_url + str(rev_ctx)
summary = "* [{summary}]({url})".format( summary = "* [{summary}]({url})".format(summary=one_liner, url=summary_url)
summary=one_liner, url=summary_url)
else: else:
summary = "* {summary}".format(summary=one_liner) summary = "* {summary}".format(summary=one_liner)
@ -65,14 +69,17 @@ def format_commit_lines(web_url: str, repo: repo, base: int, tip: int) -> str:
return "\n".join(summary for summary in commit_summaries) return "\n".join(summary for summary in commit_summaries)
def send_zulip(email: str, api_key: str, site: str, stream: str, subject: str, content: Text) -> None:
def send_zulip(
email: str, api_key: str, site: str, stream: str, subject: str, content: Text
) -> None:
""" """
Send a message to Zulip using the provided credentials, which should be for Send a message to Zulip using the provided credentials, which should be for
a bot in most cases. a bot in most cases.
""" """
client = zulip.Client(email=email, api_key=api_key, client = zulip.Client(
site=site, email=email, api_key=api_key, site=site, client="ZulipMercurial/" + VERSION
client="ZulipMercurial/" + VERSION) )
message_data = { message_data = {
"type": "stream", "type": "stream",
@ -83,6 +90,7 @@ def send_zulip(email: str, api_key: str, site: str, stream: str, subject: str, c
client.send_message(message_data) client.send_message(message_data)
def get_config(ui: ui, item: str) -> str: def get_config(ui: ui, item: str) -> str:
try: try:
# config returns configuration value. # config returns configuration value.
@ -91,6 +99,7 @@ def get_config(ui: ui, item: str) -> str:
ui.warn("Zulip: Could not find required item {} in hg config.".format(item)) ui.warn("Zulip: Could not find required item {} in hg config.".format(item))
sys.exit(1) sys.exit(1)
def hook(ui: ui, repo: repo, **kwargs: Text) -> None: def hook(ui: ui, repo: repo, **kwargs: Text) -> None:
""" """
Invoked by configuring a [hook] entry in .hg/hgrc. Invoked by configuring a [hook] entry in .hg/hgrc.

View file

@ -14,6 +14,7 @@ def die(signal: int, frame: FrameType) -> None:
"""We actually want to exit, so run os._exit (so as not to be caught and restarted)""" """We actually want to exit, so run os._exit (so as not to be caught and restarted)"""
os._exit(1) os._exit(1)
signal.signal(signal.SIGINT, die) signal.signal(signal.SIGINT, die)
args = [os.path.join(os.path.dirname(sys.argv[0]), "jabber_mirror_backend.py")] args = [os.path.join(os.path.dirname(sys.argv[0]), "jabber_mirror_backend.py")]

View file

@ -52,18 +52,22 @@ from zulip import Client
__version__ = "1.1" __version__ = "1.1"
def room_to_stream(room: str) -> str: def room_to_stream(room: str) -> str:
return room + "/xmpp" return room + "/xmpp"
def stream_to_room(stream: str) -> str: def stream_to_room(stream: str) -> str:
return stream.lower().rpartition("/xmpp")[0] return stream.lower().rpartition("/xmpp")[0]
def jid_to_zulip(jid: JID) -> str: def jid_to_zulip(jid: JID) -> str:
suffix = '' suffix = ''
if not jid.username.endswith("-bot"): if not jid.username.endswith("-bot"):
suffix = options.zulip_email_suffix suffix = options.zulip_email_suffix
return "%s%s@%s" % (jid.username, suffix, options.zulip_domain) return "%s%s@%s" % (jid.username, suffix, options.zulip_domain)
def zulip_to_jid(email: str, jabber_domain: str) -> JID: def zulip_to_jid(email: str, jabber_domain: str) -> JID:
jid = JID(email, domain=jabber_domain) jid = JID(email, domain=jabber_domain)
if ( if (
@ -74,6 +78,7 @@ def zulip_to_jid(email: str, jabber_domain: str) -> JID:
jid.username = jid.username.rpartition(options.zulip_email_suffix)[0] jid.username = jid.username.rpartition(options.zulip_email_suffix)[0]
return jid return jid
class JabberToZulipBot(ClientXMPP): class JabberToZulipBot(ClientXMPP):
def __init__(self, jid: JID, password: str, rooms: List[str]) -> None: def __init__(self, jid: JID, password: str, rooms: List[str]) -> None:
if jid.resource: if jid.resource:
@ -153,10 +158,10 @@ class JabberToZulipBot(ClientXMPP):
recipient = jid_to_zulip(msg["to"]) recipient = jid_to_zulip(msg["to"])
zulip_message = dict( zulip_message = dict(
sender = sender, sender=sender,
type = "private", type="private",
to = recipient, to=recipient,
content = msg["body"], content=msg["body"],
) )
ret = self.zulipToJabber.client.send_message(zulip_message) ret = self.zulipToJabber.client.send_message(zulip_message)
if ret.get("result") != "success": if ret.get("result") != "success":
@ -178,12 +183,12 @@ class JabberToZulipBot(ClientXMPP):
jid = self.nickname_to_jid(msg.get_mucroom(), sender_nick) jid = self.nickname_to_jid(msg.get_mucroom(), sender_nick)
sender = jid_to_zulip(jid) sender = jid_to_zulip(jid)
zulip_message = dict( zulip_message = dict(
forged = "yes", forged="yes",
sender = sender, sender=sender,
type = "stream", type="stream",
subject = subject, subject=subject,
to = stream, to=stream,
content = msg["body"], content=msg["body"],
) )
ret = self.zulipToJabber.client.send_message(zulip_message) ret = self.zulipToJabber.client.send_message(zulip_message)
if ret.get("result") != "success": if ret.get("result") != "success":
@ -191,11 +196,12 @@ class JabberToZulipBot(ClientXMPP):
def nickname_to_jid(self, room: str, nick: str) -> JID: def nickname_to_jid(self, room: str, nick: str) -> JID:
jid = self.plugin['xep_0045'].getJidProperty(room, nick, "jid") jid = self.plugin['xep_0045'].getJidProperty(room, nick, "jid")
if (jid is None or jid == ''): if jid is None or jid == '':
return JID(local=nick.replace(' ', ''), domain=self.boundjid.domain) return JID(local=nick.replace(' ', ''), domain=self.boundjid.domain)
else: else:
return jid return jid
class ZulipToJabberBot: class ZulipToJabberBot:
def __init__(self, zulip_client: Client) -> None: def __init__(self, zulip_client: Client) -> None:
self.client = zulip_client self.client = zulip_client
@ -221,7 +227,7 @@ class ZulipToJabberBot:
self.process_subscription(event) self.process_subscription(event)
def stream_message(self, msg: Dict[str, str]) -> None: def stream_message(self, msg: Dict[str, str]) -> None:
assert(self.jabber is not None) assert self.jabber is not None
stream = msg['display_recipient'] stream = msg['display_recipient']
if not stream.endswith("/xmpp"): if not stream.endswith("/xmpp"):
return return
@ -229,14 +235,13 @@ class ZulipToJabberBot:
room = stream_to_room(stream) room = stream_to_room(stream)
jabber_recipient = JID(local=room, domain=options.conference_domain) jabber_recipient = JID(local=room, domain=options.conference_domain)
outgoing = self.jabber.make_message( outgoing = self.jabber.make_message(
mto = jabber_recipient, mto=jabber_recipient, mbody=msg['content'], mtype='groupchat'
mbody = msg['content'], )
mtype = 'groupchat')
outgoing['thread'] = '\u1FFFE' outgoing['thread'] = '\u1FFFE'
outgoing.send() outgoing.send()
def private_message(self, msg: Dict[str, Any]) -> None: def private_message(self, msg: Dict[str, Any]) -> None:
assert(self.jabber is not None) assert self.jabber is not None
for recipient in msg['display_recipient']: for recipient in msg['display_recipient']:
if recipient["email"] == self.client.email: if recipient["email"] == self.client.email:
continue continue
@ -245,14 +250,13 @@ class ZulipToJabberBot:
recip_email = recipient['email'] recip_email = recipient['email']
jabber_recipient = zulip_to_jid(recip_email, self.jabber.boundjid.domain) jabber_recipient = zulip_to_jid(recip_email, self.jabber.boundjid.domain)
outgoing = self.jabber.make_message( outgoing = self.jabber.make_message(
mto = jabber_recipient, mto=jabber_recipient, mbody=msg['content'], mtype='chat'
mbody = msg['content'], )
mtype = 'chat')
outgoing['thread'] = '\u1FFFE' outgoing['thread'] = '\u1FFFE'
outgoing.send() outgoing.send()
def process_subscription(self, event: Dict[str, Any]) -> None: def process_subscription(self, event: Dict[str, Any]) -> None:
assert(self.jabber is not None) assert self.jabber is not None
if event['op'] == 'add': if event['op'] == 'add':
streams = [s['name'].lower() for s in event['subscriptions']] streams = [s['name'].lower() for s in event['subscriptions']]
streams = [s for s in streams if s.endswith("/xmpp")] streams = [s for s in streams if s.endswith("/xmpp")]
@ -264,6 +268,7 @@ class ZulipToJabberBot:
for stream in streams: for stream in streams:
self.jabber.leave_muc(stream_to_room(stream)) self.jabber.leave_muc(stream_to_room(stream))
def get_rooms(zulipToJabber: ZulipToJabberBot) -> List[str]: def get_rooms(zulipToJabber: ZulipToJabberBot) -> List[str]:
def get_stream_infos(key: str, method: Callable[[], Dict[str, Any]]) -> Any: def get_stream_infos(key: str, method: Callable[[], Dict[str, Any]]) -> Any:
ret = method() ret = method()
@ -284,17 +289,21 @@ def get_rooms(zulipToJabber: ZulipToJabberBot) -> List[str]:
rooms.append(stream_to_room(stream)) rooms.append(stream_to_room(stream))
return rooms return rooms
def config_error(msg: str) -> None: def config_error(msg: str) -> None:
sys.stderr.write("%s\n" % (msg,)) sys.stderr.write("%s\n" % (msg,))
sys.exit(2) sys.exit(2)
if __name__ == '__main__': if __name__ == '__main__':
parser = optparse.OptionParser( parser = optparse.OptionParser(
epilog='''Most general and Jabber configuration options may also be specified in the epilog='''Most general and Jabber configuration options may also be specified in the
zulip configuration file under the jabber_mirror section (exceptions are noted zulip configuration file under the jabber_mirror section (exceptions are noted
in their help sections). Keys have the same name as options with hyphens in their help sections). Keys have the same name as options with hyphens
replaced with underscores. Zulip configuration options go in the api section, replaced with underscores. Zulip configuration options go in the api section,
as normal.'''.replace("\n", " ") as normal.'''.replace(
"\n", " "
)
) )
parser.add_option( parser.add_option(
'--mode', '--mode',
@ -305,7 +314,10 @@ as normal.'''.replace("\n", " ")
all messages they send on Zulip to Jabber and all private Jabber messages to all messages they send on Zulip to Jabber and all private Jabber messages to
Zulip. In "public" mode, the mirror uses the credentials for a dedicated mirror Zulip. In "public" mode, the mirror uses the credentials for a dedicated mirror
user and mirrors messages sent to Jabber rooms to Zulip. Defaults to user and mirrors messages sent to Jabber rooms to Zulip. Defaults to
"personal"'''.replace("\n", " ")) "personal"'''.replace(
"\n", " "
),
)
parser.add_option( parser.add_option(
'--zulip-email-suffix', '--zulip-email-suffix',
default=None, default=None,
@ -315,13 +327,19 @@ from JIDs and nicks before sending requests to the Zulip server, and remove the
suffix before sending requests to the Jabber server. For example, specifying suffix before sending requests to the Jabber server. For example, specifying
"+foo" will cause messages that are sent to the "bar" room by nickname "qux" to "+foo" will cause messages that are sent to the "bar" room by nickname "qux" to
be mirrored to the "bar/xmpp" stream in Zulip by user "qux+foo@example.com". This be mirrored to the "bar/xmpp" stream in Zulip by user "qux+foo@example.com". This
option does not affect login credentials.'''.replace("\n", " ")) option does not affect login credentials.'''.replace(
parser.add_option('-d', '--debug', "\n", " "
help='set logging to DEBUG. Can not be set via config file.', ),
action='store_const', )
dest='log_level', parser.add_option(
const=logging.DEBUG, '-d',
default=logging.INFO) '--debug',
help='set logging to DEBUG. Can not be set via config file.',
action='store_const',
dest='log_level',
const=logging.DEBUG,
default=logging.INFO,
)
jabber_group = optparse.OptionGroup(parser, "Jabber configuration") jabber_group = optparse.OptionGroup(parser, "Jabber configuration")
jabber_group.add_option( jabber_group.add_option(
@ -329,39 +347,42 @@ option does not affect login credentials.'''.replace("\n", " "))
default=None, default=None,
action='store', action='store',
help="Your Jabber JID. If a resource is specified, " help="Your Jabber JID. If a resource is specified, "
"it will be used as the nickname when joining MUCs. " "it will be used as the nickname when joining MUCs. "
"Specifying the nickname is mostly useful if you want " "Specifying the nickname is mostly useful if you want "
"to run the public mirror from a regular user instead of " "to run the public mirror from a regular user instead of "
"from a dedicated account.") "from a dedicated account.",
jabber_group.add_option('--jabber-password', )
default=None, jabber_group.add_option(
action='store', '--jabber-password', default=None, action='store', help="Your Jabber password"
help="Your Jabber password") )
jabber_group.add_option('--conference-domain', jabber_group.add_option(
default=None, '--conference-domain',
action='store', default=None,
help="Your Jabber conference domain (E.g. conference.jabber.example.com). " action='store',
"If not specifed, \"conference.\" will be prepended to your JID's domain.") help="Your Jabber conference domain (E.g. conference.jabber.example.com). "
jabber_group.add_option('--no-use-tls', "If not specifed, \"conference.\" will be prepended to your JID's domain.",
default=None, )
action='store_true') jabber_group.add_option('--no-use-tls', default=None, action='store_true')
jabber_group.add_option('--jabber-server-address', jabber_group.add_option(
default=None, '--jabber-server-address',
action='store', default=None,
help="The hostname of your Jabber server. This is only needed if " action='store',
"your server is missing SRV records") help="The hostname of your Jabber server. This is only needed if "
jabber_group.add_option('--jabber-server-port', "your server is missing SRV records",
default='5222', )
action='store', jabber_group.add_option(
help="The port of your Jabber server. This is only needed if " '--jabber-server-port',
"your server is missing SRV records") default='5222',
action='store',
help="The port of your Jabber server. This is only needed if "
"your server is missing SRV records",
)
parser.add_option_group(jabber_group) parser.add_option_group(jabber_group)
parser.add_option_group(zulip.generate_option_group(parser, "zulip-")) parser.add_option_group(zulip.generate_option_group(parser, "zulip-"))
(options, args) = parser.parse_args() (options, args) = parser.parse_args()
logging.basicConfig(level=options.log_level, logging.basicConfig(level=options.log_level, format='%(levelname)-8s %(message)s')
format='%(levelname)-8s %(message)s')
if options.zulip_config_file is None: if options.zulip_config_file is None:
default_config_file = zulip.get_default_config_filename() default_config_file = zulip.get_default_config_filename()
@ -378,12 +399,16 @@ option does not affect login credentials.'''.replace("\n", " "))
config.readfp(f, config_file) config.readfp(f, config_file)
except OSError: except OSError:
pass pass
for option in ("jid", "jabber_password", "conference_domain", "mode", "zulip_email_suffix", for option in (
"jabber_server_address", "jabber_server_port"): "jid",
if ( "jabber_password",
getattr(options, option) is None "conference_domain",
and config.has_option("jabber_mirror", option) "mode",
): "zulip_email_suffix",
"jabber_server_address",
"jabber_server_port",
):
if getattr(options, option) is None and config.has_option("jabber_mirror", option):
setattr(options, option, config.get("jabber_mirror", option)) setattr(options, option, config.get("jabber_mirror", option))
for option in ("no_use_tls",): for option in ("no_use_tls",):
@ -403,10 +428,14 @@ option does not affect login credentials.'''.replace("\n", " "))
config_error("Bad value for --mode: must be one of 'public' or 'personal'") config_error("Bad value for --mode: must be one of 'public' or 'personal'")
if None in (options.jid, options.jabber_password): if None in (options.jid, options.jabber_password):
config_error("You must specify your Jabber JID and Jabber password either " config_error(
"in the Zulip configuration file or on the commandline") "You must specify your Jabber JID and Jabber password either "
"in the Zulip configuration file or on the commandline"
)
zulipToJabber = ZulipToJabberBot(zulip.init_from_options(options, "JabberMirror/" + __version__)) zulipToJabber = ZulipToJabberBot(
zulip.init_from_options(options, "JabberMirror/" + __version__)
)
# This won't work for open realms that don't have a consistent domain # This won't work for open realms that don't have a consistent domain
options.zulip_domain = zulipToJabber.client.email.partition('@')[-1] options.zulip_domain = zulipToJabber.client.email.partition('@')[-1]
@ -438,8 +467,9 @@ option does not affect login credentials.'''.replace("\n", " "))
try: try:
logging.info("Connecting to Zulip.") logging.info("Connecting to Zulip.")
zulipToJabber.client.call_on_each_event(zulipToJabber.process_event, zulipToJabber.client.call_on_each_event(
event_types=event_types) zulipToJabber.process_event, event_types=event_types
)
except BaseException: except BaseException:
logging.exception("Exception in main loop") logging.exception("Exception in main loop")
xmpp.abort() xmpp.abort()

View file

@ -14,10 +14,12 @@ import traceback
sys.path.append("/home/zulip/deployments/current") sys.path.append("/home/zulip/deployments/current")
try: try:
from scripts.lib.setup_path import setup_path from scripts.lib.setup_path import setup_path
setup_path() setup_path()
except ImportError: except ImportError:
try: try:
import scripts.lib.setup_path_on_import import scripts.lib.setup_path_on_import
scripts.lib.setup_path_on_import # Suppress unused import warning scripts.lib.setup_path_on_import # Suppress unused import warning
except ImportError: except ImportError:
pass pass
@ -31,6 +33,7 @@ import zulip
temp_dir = "/var/tmp/" if os.name == "posix" else tempfile.gettempdir() temp_dir = "/var/tmp/" if os.name == "posix" else tempfile.gettempdir()
def mkdir_p(path: str) -> None: def mkdir_p(path: str) -> None:
# Python doesn't have an analog to `mkdir -p` < Python 3.2. # Python doesn't have an analog to `mkdir -p` < Python 3.2.
try: try:
@ -41,14 +44,18 @@ def mkdir_p(path: str) -> None:
else: else:
raise raise
def send_log_zulip(file_name: str, count: int, lines: List[str], extra: str = "") -> None: def send_log_zulip(file_name: str, count: int, lines: List[str], extra: str = "") -> None:
content = "%s new errors%s:\n```\n%s\n```" % (count, extra, "\n".join(lines)) content = "%s new errors%s:\n```\n%s\n```" % (count, extra, "\n".join(lines))
zulip_client.send_message({ zulip_client.send_message(
"type": "stream", {
"to": "logs", "type": "stream",
"subject": "%s on %s" % (file_name, platform.node()), "to": "logs",
"content": content, "subject": "%s on %s" % (file_name, platform.node()),
}) "content": content,
}
)
def process_lines(raw_lines: List[str], file_name: str) -> None: def process_lines(raw_lines: List[str], file_name: str) -> None:
lines = [] lines = []
@ -65,6 +72,7 @@ def process_lines(raw_lines: List[str], file_name: str) -> None:
else: else:
send_log_zulip(file_name, len(lines), lines) send_log_zulip(file_name, len(lines), lines)
def process_logs() -> None: def process_logs() -> None:
data_file_path = os.path.join(temp_dir, "log2zulip.state") data_file_path = os.path.join(temp_dir, "log2zulip.state")
mkdir_p(os.path.dirname(data_file_path)) mkdir_p(os.path.dirname(data_file_path))
@ -95,6 +103,7 @@ def process_logs() -> None:
new_data[log_file] = file_data new_data[log_file] = file_data
open(data_file_path, "w").write(json.dumps(new_data)) open(data_file_path, "w").write(json.dumps(new_data))
if __name__ == "__main__": if __name__ == "__main__":
parser = zulip.add_default_arguments(argparse.ArgumentParser()) # type: argparse.ArgumentParser parser = zulip.add_default_arguments(argparse.ArgumentParser()) # type: argparse.ArgumentParser
parser.add_argument("--control-path", default="/etc/log2zulip.conf") parser.add_argument("--control-path", default="/etc/log2zulip.conf")

View file

@ -17,8 +17,9 @@ for opt in ('type', 'host', 'service', 'state'):
parser.add_argument('--' + opt) parser.add_argument('--' + opt)
opts = parser.parse_args() opts = parser.parse_args()
client = zulip.Client(config_file=opts.config, client = zulip.Client(
client="ZulipNagios/" + VERSION) # type: zulip.Client config_file=opts.config, client="ZulipNagios/" + VERSION
) # type: zulip.Client
msg = dict(type='stream', to=opts.stream) # type: Dict[str, Any] msg = dict(type='stream', to=opts.stream) # type: Dict[str, Any]
@ -47,6 +48,6 @@ msg['content'] = '**%s**: %s is %s' % (opts.type, thing, opts.state)
output = (opts.output + '\n' + opts.long_output.replace(r'\n', '\n')).strip() # type: Text output = (opts.output + '\n' + opts.long_output.replace(r'\n', '\n')).strip() # type: Text
if output: if output:
# Put any command output in a code block. # Put any command output in a code block.
msg['content'] += ('\n\n~~~~\n' + output + "\n~~~~\n") msg['content'] += '\n\n~~~~\n' + output + "\n~~~~\n"
client.send_message(msg) client.send_message(msg)

View file

@ -21,7 +21,9 @@ client = zulip.Client(
email=config.ZULIP_USER, email=config.ZULIP_USER,
site=config.ZULIP_SITE, site=config.ZULIP_SITE,
api_key=config.ZULIP_API_KEY, api_key=config.ZULIP_API_KEY,
client='ZulipOpenShift/' + VERSION) client='ZulipOpenShift/' + VERSION,
)
def get_deployment_details() -> Dict[str, str]: def get_deployment_details() -> Dict[str, str]:
# "gear deployments" output example: # "gear deployments" output example:
@ -30,10 +32,13 @@ def get_deployment_details() -> Dict[str, str]:
dep = subprocess.check_output(['gear', 'deployments'], universal_newlines=True).splitlines()[1] dep = subprocess.check_output(['gear', 'deployments'], universal_newlines=True).splitlines()[1]
splits = dep.split(' - ') splits = dep.split(' - ')
return dict(app_name=os.environ['OPENSHIFT_APP_NAME'], return dict(
url=os.environ['OPENSHIFT_APP_DNS'], app_name=os.environ['OPENSHIFT_APP_NAME'],
branch=splits[2], url=os.environ['OPENSHIFT_APP_DNS'],
commit_id=splits[3]) branch=splits[2],
commit_id=splits[3],
)
def send_bot_message(deployment: Dict[str, str]) -> None: def send_bot_message(deployment: Dict[str, str]) -> None:
destination = config.deployment_notice_destination(deployment['branch']) destination = config.deployment_notice_destination(deployment['branch'])
@ -42,14 +47,17 @@ def send_bot_message(deployment: Dict[str, str]) -> None:
return return
message = config.format_deployment_message(**deployment) message = config.format_deployment_message(**deployment)
client.send_message({ client.send_message(
'type': 'stream', {
'to': destination['stream'], 'type': 'stream',
'subject': destination['subject'], 'to': destination['stream'],
'content': message, 'subject': destination['subject'],
}) 'content': message,
}
)
return return
deployment = get_deployment_details() deployment = get_deployment_details()
send_bot_message(deployment) send_bot_message(deployment)

View file

@ -21,12 +21,12 @@ ZULIP_API_KEY = '0123456789abcdef0123456789abcdef'
# And similarly for branch "test-post-receive" (for use when testing). # And similarly for branch "test-post-receive" (for use when testing).
def deployment_notice_destination(branch: str) -> Optional[Dict[str, Text]]: def deployment_notice_destination(branch: str) -> Optional[Dict[str, Text]]:
if branch in ['master', 'test-post-receive']: if branch in ['master', 'test-post-receive']:
return dict(stream = 'deployments', return dict(stream='deployments', subject='%s' % (branch,))
subject = '%s' % (branch,))
# Return None for cases where you don't want a notice sent # Return None for cases where you don't want a notice sent
return None return None
# Modify this function to change how deployments are displayed # Modify this function to change how deployments are displayed
# #
# It takes the following arguments: # It takes the following arguments:
@ -39,9 +39,15 @@ def deployment_notice_destination(branch: str) -> Optional[Dict[str, Text]]:
# * dep_id = deployment id # * dep_id = deployment id
# * dep_time = deployment timestamp # * dep_time = deployment timestamp
def format_deployment_message( def format_deployment_message(
app_name: str = '', url: str = '', branch: str = '', commit_id: str = '', dep_id: str = '', dep_time: str = '') -> str: app_name: str = '',
return 'Deployed commit `%s` (%s) in [%s](%s)' % ( url: str = '',
commit_id, branch, app_name, url) branch: str = '',
commit_id: str = '',
dep_id: str = '',
dep_time: str = '',
) -> str:
return 'Deployed commit `%s` (%s) in [%s](%s)' % (commit_id, branch, app_name, url)
## If properly installed, the Zulip API should be in your import ## If properly installed, the Zulip API should be in your import
## path, but if not, set a custom path below ## path, but if not, set a custom path below

View file

@ -36,7 +36,8 @@ client = zulip.Client(
email=config.ZULIP_USER, email=config.ZULIP_USER,
site=config.ZULIP_SITE, site=config.ZULIP_SITE,
api_key=config.ZULIP_API_KEY, api_key=config.ZULIP_API_KEY,
client="ZulipPerforce/" + __version__) # type: zulip.Client client="ZulipPerforce/" + __version__,
) # type: zulip.Client
try: try:
changelist = int(sys.argv[1]) # type: int changelist = int(sys.argv[1]) # type: int
@ -52,7 +53,9 @@ except ValueError:
metadata = git_p4.p4_describe(changelist) # type: Dict[str, str] metadata = git_p4.p4_describe(changelist) # type: Dict[str, str]
destination = config.commit_notice_destination(changeroot, changelist) # type: Optional[Dict[str, str]] destination = config.commit_notice_destination(
changeroot, changelist
) # type: Optional[Dict[str, str]]
if destination is None: if destination is None:
# Don't forward the notice anywhere # Don't forward the notice anywhere
@ -84,10 +87,8 @@ message = """**{user}** committed revision @{change} to `{path}`.
{desc} {desc}
``` ```
""".format( """.format(
user=metadata["user"], user=metadata["user"], change=change, path=changeroot, desc=metadata["desc"]
change=change, ) # type: str
path=changeroot,
desc=metadata["desc"]) # type: str
message_data = { message_data = {
"type": "stream", "type": "stream",

View file

@ -37,12 +37,12 @@ def commit_notice_destination(path: Text, changelist: int) -> Optional[Dict[Text
directory = dirs[2] directory = dirs[2]
if directory not in ["evil-master-plan", "my-super-secret-repository"]: if directory not in ["evil-master-plan", "my-super-secret-repository"]:
return dict(stream = "%s-commits" % (directory,), return dict(stream="%s-commits" % (directory,), subject=path)
subject = path)
# Return None for cases where you don't want a notice sent # Return None for cases where you don't want a notice sent
return None return None
## If properly installed, the Zulip API should be in your import ## If properly installed, the Zulip API should be in your import
## path, but if not, set a custom path below ## path, but if not, set a custom path below
ZULIP_API_PATH: Optional[str] = None ZULIP_API_PATH: Optional[str] = None

View file

@ -48,35 +48,48 @@ stream every 5 minutes is:
*/5 * * * * /usr/local/share/zulip/integrations/rss/rss-bot""" */5 * * * * /usr/local/share/zulip/integrations/rss/rss-bot"""
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage)) # type: argparse.ArgumentParser parser = zulip.add_default_arguments(
parser.add_argument('--stream', argparse.ArgumentParser(usage)
dest='stream', ) # type: argparse.ArgumentParser
help='The stream to which to send RSS messages.', parser.add_argument(
default="rss", '--stream',
action='store') dest='stream',
parser.add_argument('--data-dir', help='The stream to which to send RSS messages.',
dest='data_dir', default="rss",
help='The directory where feed metadata is stored', action='store',
default=os.path.join(RSS_DATA_DIR), )
action='store') parser.add_argument(
parser.add_argument('--feed-file', '--data-dir',
dest='feed_file', dest='data_dir',
help='The file containing a list of RSS feed URLs to follow, one URL per line', help='The directory where feed metadata is stored',
default=os.path.join(RSS_DATA_DIR, "rss-feeds"), default=os.path.join(RSS_DATA_DIR),
action='store') action='store',
parser.add_argument('--unwrap', )
dest='unwrap', parser.add_argument(
action='store_true', '--feed-file',
help='Convert word-wrapped paragraphs into single lines', dest='feed_file',
default=False) help='The file containing a list of RSS feed URLs to follow, one URL per line',
parser.add_argument('--math', default=os.path.join(RSS_DATA_DIR, "rss-feeds"),
dest='math', action='store',
action='store_true', )
help='Convert $ to $$ (for KaTeX processing)', parser.add_argument(
default=False) '--unwrap',
dest='unwrap',
action='store_true',
help='Convert word-wrapped paragraphs into single lines',
default=False,
)
parser.add_argument(
'--math',
dest='math',
action='store_true',
help='Convert $ to $$ (for KaTeX processing)',
default=False,
)
opts = parser.parse_args() # type: Any opts = parser.parse_args() # type: Any
def mkdir_p(path: str) -> None: def mkdir_p(path: str) -> None:
# Python doesn't have an analog to `mkdir -p` < Python 3.2. # Python doesn't have an analog to `mkdir -p` < Python 3.2.
try: try:
@ -87,6 +100,7 @@ def mkdir_p(path: str) -> None:
else: else:
raise raise
try: try:
mkdir_p(opts.data_dir) mkdir_p(opts.data_dir)
except OSError: except OSError:
@ -106,11 +120,13 @@ logger = logging.getLogger(__name__) # type: logging.Logger
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
logger.addHandler(file_handler) logger.addHandler(file_handler)
def log_error_and_exit(error: str) -> None: def log_error_and_exit(error: str) -> None:
logger.error(error) logger.error(error)
logger.error(usage) logger.error(usage)
exit(1) exit(1)
class MLStripper(HTMLParser): class MLStripper(HTMLParser):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@ -123,57 +139,70 @@ class MLStripper(HTMLParser):
def get_data(self) -> str: def get_data(self) -> str:
return ''.join(self.fed) return ''.join(self.fed)
def strip_tags(html: str) -> str: def strip_tags(html: str) -> str:
stripper = MLStripper() stripper = MLStripper()
stripper.feed(html) stripper.feed(html)
return stripper.get_data() return stripper.get_data()
def compute_entry_hash(entry: Dict[str, Any]) -> str: def compute_entry_hash(entry: Dict[str, Any]) -> str:
entry_time = entry.get("published", entry.get("updated")) entry_time = entry.get("published", entry.get("updated"))
entry_id = entry.get("id", entry.get("link")) entry_id = entry.get("id", entry.get("link"))
return hashlib.md5((entry_id + str(entry_time)).encode()).hexdigest() return hashlib.md5((entry_id + str(entry_time)).encode()).hexdigest()
def unwrap_text(body: str) -> str: def unwrap_text(body: str) -> str:
# Replace \n by space if it is preceded and followed by a non-\n. # Replace \n by space if it is preceded and followed by a non-\n.
# Example: '\na\nb\nc\n\nd\n' -> '\na b c\n\nd\n' # Example: '\na\nb\nc\n\nd\n' -> '\na b c\n\nd\n'
return re.sub('(?<=[^\n])\n(?=[^\n])', ' ', body) return re.sub('(?<=[^\n])\n(?=[^\n])', ' ', body)
def elide_subject(subject: str) -> str: def elide_subject(subject: str) -> str:
MAX_TOPIC_LENGTH = 60 MAX_TOPIC_LENGTH = 60
if len(subject) > MAX_TOPIC_LENGTH: if len(subject) > MAX_TOPIC_LENGTH:
subject = subject[:MAX_TOPIC_LENGTH - 3].rstrip() + '...' subject = subject[: MAX_TOPIC_LENGTH - 3].rstrip() + '...'
return subject return subject
def send_zulip(entry: Any, feed_name: str) -> Dict[str, Any]: def send_zulip(entry: Any, feed_name: str) -> Dict[str, Any]:
body = entry.summary # type: str body = entry.summary # type: str
if opts.unwrap: if opts.unwrap:
body = unwrap_text(body) body = unwrap_text(body)
content = "**[%s](%s)**\n%s\n%s" % (entry.title, content = "**[%s](%s)**\n%s\n%s" % (
entry.link, entry.title,
strip_tags(body), entry.link,
entry.link) # type: str strip_tags(body),
entry.link,
) # type: str
if opts.math: if opts.math:
content = content.replace('$', '$$') content = content.replace('$', '$$')
message = {"type": "stream", message = {
"sender": opts.zulip_email, "type": "stream",
"to": opts.stream, "sender": opts.zulip_email,
"subject": elide_subject(feed_name), "to": opts.stream,
"content": content, "subject": elide_subject(feed_name),
} # type: Dict[str, str] "content": content,
} # type: Dict[str, str]
return client.send_message(message) return client.send_message(message)
try: try:
with open(opts.feed_file) as f: with open(opts.feed_file) as f:
feed_urls = [feed.strip() for feed in f.readlines()] # type: List[str] feed_urls = [feed.strip() for feed in f.readlines()] # type: List[str]
except OSError: except OSError:
log_error_and_exit("Unable to read feed file at %s." % (opts.feed_file,)) log_error_and_exit("Unable to read feed file at %s." % (opts.feed_file,))
client = zulip.Client(email=opts.zulip_email, api_key=opts.zulip_api_key, client = zulip.Client(
config_file=opts.zulip_config_file, email=opts.zulip_email,
site=opts.zulip_site, client="ZulipRSS/" + VERSION) # type: zulip.Client api_key=opts.zulip_api_key,
config_file=opts.zulip_config_file,
site=opts.zulip_site,
client="ZulipRSS/" + VERSION,
) # type: zulip.Client
first_message = True # type: bool first_message = True # type: bool
@ -182,7 +211,9 @@ for feed_url in feed_urls:
try: try:
with open(feed_file) as f: with open(feed_file) as f:
old_feed_hashes = {line.strip(): True for line in f.readlines()} # type: Dict[str, bool] old_feed_hashes = {
line.strip(): True for line in f.readlines()
} # type: Dict[str, bool]
except OSError: except OSError:
old_feed_hashes = {} old_feed_hashes = {}
@ -192,8 +223,13 @@ for feed_url in feed_urls:
for entry in data.entries: for entry in data.entries:
entry_hash = compute_entry_hash(entry) # type: str entry_hash = compute_entry_hash(entry) # type: str
# An entry has either been published or updated. # An entry has either been published or updated.
entry_time = entry.get("published_parsed", entry.get("updated_parsed")) # type: Tuple[int, int] entry_time = entry.get(
if entry_time is not None and (time.time() - calendar.timegm(entry_time)) > OLDNESS_THRESHOLD * 60 * 60 * 24: "published_parsed", entry.get("updated_parsed")
) # type: Tuple[int, int]
if (
entry_time is not None
and (time.time() - calendar.timegm(entry_time)) > OLDNESS_THRESHOLD * 60 * 60 * 24
):
# As a safeguard against misbehaving feeds, don't try to process # As a safeguard against misbehaving feeds, don't try to process
# entries older than some threshold. # entries older than some threshold.
continue continue

View file

@ -30,7 +30,8 @@ client = zulip.Client(
email=config.ZULIP_USER, email=config.ZULIP_USER,
site=config.ZULIP_SITE, site=config.ZULIP_SITE,
api_key=config.ZULIP_API_KEY, api_key=config.ZULIP_API_KEY,
client="ZulipSVN/" + VERSION) # type: zulip.Client client="ZulipSVN/" + VERSION,
) # type: zulip.Client
svn = pysvn.Client() # type: pysvn.Client svn = pysvn.Client() # type: pysvn.Client
path, rev = sys.argv[1:] # type: Tuple[Text, Text] path, rev = sys.argv[1:] # type: Tuple[Text, Text]
@ -38,12 +39,12 @@ path, rev = sys.argv[1:] # type: Tuple[Text, Text]
# since its a local path, prepend "file://" # since its a local path, prepend "file://"
path = "file://" + path path = "file://" + path
entry = svn.log(path, revision_end=pysvn.Revision(pysvn.opt_revision_kind.number, rev))[0] # type: Dict[Text, Any] entry = svn.log(path, revision_end=pysvn.Revision(pysvn.opt_revision_kind.number, rev))[
0
] # type: Dict[Text, Any]
message = "**{}** committed revision r{} to `{}`.\n\n> {}".format( message = "**{}** committed revision r{} to `{}`.\n\n> {}".format(
entry['author'], entry['author'], rev, path.split('/')[-1], entry['revprops']['svn:log']
rev, ) # type: Text
path.split('/')[-1],
entry['revprops']['svn:log']) # type: Text
destination = config.commit_notice_destination(path, rev) # type: Optional[Dict[Text, Text]] destination = config.commit_notice_destination(path, rev) # type: Optional[Dict[Text, Text]]

View file

@ -21,12 +21,12 @@ ZULIP_API_KEY = "0123456789abcdef0123456789abcdef"
def commit_notice_destination(path: Text, commit: Text) -> Optional[Dict[Text, Text]]: def commit_notice_destination(path: Text, commit: Text) -> Optional[Dict[Text, Text]]:
repo = path.split('/')[-1] repo = path.split('/')[-1]
if repo not in ["evil-master-plan", "my-super-secret-repository"]: if repo not in ["evil-master-plan", "my-super-secret-repository"]:
return dict(stream = "commits", return dict(stream="commits", subject="%s" % (repo,))
subject = "%s" % (repo,))
# Return None for cases where you don't want a notice sent # Return None for cases where you don't want a notice sent
return None return None
## If properly installed, the Zulip API should be in your import ## If properly installed, the Zulip API should be in your import
## path, but if not, set a custom path below ## path, but if not, set a custom path below
ZULIP_API_PATH: Optional[str] = None ZULIP_API_PATH: Optional[str] = None

View file

@ -33,38 +33,50 @@ client = zulip.Client(
email=config.ZULIP_USER, email=config.ZULIP_USER,
site=config.ZULIP_SITE, site=config.ZULIP_SITE,
api_key=config.ZULIP_API_KEY, api_key=config.ZULIP_API_KEY,
client="ZulipTrac/" + VERSION) client="ZulipTrac/" + VERSION,
)
def markdown_ticket_url(ticket: Any, heading: str = "ticket") -> str: def markdown_ticket_url(ticket: Any, heading: str = "ticket") -> str:
return "[%s #%s](%s/%s)" % (heading, ticket.id, config.TRAC_BASE_TICKET_URL, ticket.id) return "[%s #%s](%s/%s)" % (heading, ticket.id, config.TRAC_BASE_TICKET_URL, ticket.id)
def markdown_block(desc: str) -> str: def markdown_block(desc: str) -> str:
return "\n\n>" + "\n> ".join(desc.split("\n")) + "\n" return "\n\n>" + "\n> ".join(desc.split("\n")) + "\n"
def truncate(string: str, length: int) -> str: def truncate(string: str, length: int) -> str:
if len(string) <= length: if len(string) <= length:
return string return string
return string[:length - 3] + "..." return string[: length - 3] + "..."
def trac_subject(ticket: Any) -> str: def trac_subject(ticket: Any) -> str:
return truncate("#%s: %s" % (ticket.id, ticket.values.get("summary")), 60) return truncate("#%s: %s" % (ticket.id, ticket.values.get("summary")), 60)
def send_update(ticket: Any, content: str) -> None: def send_update(ticket: Any, content: str) -> None:
client.send_message({ client.send_message(
"type": "stream", {
"to": config.STREAM_FOR_NOTIFICATIONS, "type": "stream",
"content": content, "to": config.STREAM_FOR_NOTIFICATIONS,
"subject": trac_subject(ticket) "content": content,
}) "subject": trac_subject(ticket),
}
)
class ZulipPlugin(Component): class ZulipPlugin(Component):
implements(ITicketChangeListener) implements(ITicketChangeListener)
def ticket_created(self, ticket: Any) -> None: def ticket_created(self, ticket: Any) -> None:
"""Called when a ticket is created.""" """Called when a ticket is created."""
content = "%s created %s in component **%s**, priority **%s**:\n" % \ content = "%s created %s in component **%s**, priority **%s**:\n" % (
(ticket.values.get("reporter"), markdown_ticket_url(ticket), ticket.values.get("reporter"),
ticket.values.get("component"), ticket.values.get("priority")) markdown_ticket_url(ticket),
ticket.values.get("component"),
ticket.values.get("priority"),
)
# Include the full subject if it will be truncated # Include the full subject if it will be truncated
if len(ticket.values.get("summary")) > 60: if len(ticket.values.get("summary")) > 60:
content += "**%s**\n" % (ticket.values.get("summary"),) content += "**%s**\n" % (ticket.values.get("summary"),)
@ -72,7 +84,9 @@ class ZulipPlugin(Component):
content += "%s" % (markdown_block(ticket.values.get("description")),) content += "%s" % (markdown_block(ticket.values.get("description")),)
send_update(ticket, content) send_update(ticket, content)
def ticket_changed(self, ticket: Any, comment: str, author: str, old_values: Dict[str, Any]) -> None: def ticket_changed(
self, ticket: Any, comment: str, author: str, old_values: Dict[str, Any]
) -> None:
"""Called when a ticket is modified. """Called when a ticket is modified.
`old_values` is a dictionary containing the previous values of the `old_values` is a dictionary containing the previous values of the
@ -92,15 +106,19 @@ class ZulipPlugin(Component):
field_changes = [] field_changes = []
for key, value in old_values.items(): for key, value in old_values.items():
if key == "description": if key == "description":
content += '- Changed %s from %s\n\nto %s' % (key, markdown_block(value), content += '- Changed %s from %s\n\nto %s' % (
markdown_block(ticket.values.get(key))) key,
markdown_block(value),
markdown_block(ticket.values.get(key)),
)
elif old_values.get(key) == "": elif old_values.get(key) == "":
field_changes.append('%s: => **%s**' % (key, ticket.values.get(key))) field_changes.append('%s: => **%s**' % (key, ticket.values.get(key)))
elif ticket.values.get(key) == "": elif ticket.values.get(key) == "":
field_changes.append('%s: **%s** => ""' % (key, old_values.get(key))) field_changes.append('%s: **%s** => ""' % (key, old_values.get(key)))
else: else:
field_changes.append('%s: **%s** => **%s**' % (key, old_values.get(key), field_changes.append(
ticket.values.get(key))) '%s: **%s** => **%s**' % (key, old_values.get(key), ticket.values.get(key))
)
content += ", ".join(field_changes) content += ", ".join(field_changes)
send_update(ticket, content) send_update(ticket, content)

View file

@ -13,6 +13,7 @@ except ImportError:
print("http://docs.python-requests.org/en/master/user/install/") print("http://docs.python-requests.org/en/master/user/install/")
sys.exit(1) sys.exit(1)
def get_model_id(options): def get_model_id(options):
"""get_model_id """get_model_id
@ -24,19 +25,14 @@ def get_model_id(options):
""" """
trello_api_url = 'https://api.trello.com/1/board/{}'.format( trello_api_url = 'https://api.trello.com/1/board/{}'.format(options.trello_board_id)
options.trello_board_id
)
params = { params = {
'key': options.trello_api_key, 'key': options.trello_api_key,
'token': options.trello_token, 'token': options.trello_token,
} }
trello_response = requests.get( trello_response = requests.get(trello_api_url, params=params)
trello_api_url,
params=params
)
if trello_response.status_code != 200: if trello_response.status_code != 200:
print('Error: Can\'t get the idModel. Please check the configuration') print('Error: Can\'t get the idModel. Please check the configuration')
@ -68,13 +64,10 @@ def get_webhook_id(options, id_model):
options.trello_board_name, options.trello_board_name,
), ),
'callbackURL': options.zulip_webhook_url, 'callbackURL': options.zulip_webhook_url,
'idModel': id_model 'idModel': id_model,
} }
trello_response = requests.post( trello_response = requests.post(trello_api_url, data=data)
trello_api_url,
data=data
)
if trello_response.status_code != 200: if trello_response.status_code != 200:
print('Error: Can\'t create the Webhook:', trello_response.text) print('Error: Can\'t create the Webhook:', trello_response.text)
@ -84,6 +77,7 @@ def get_webhook_id(options, id_model):
return webhook_info_json['id'] return webhook_info_json['id']
def create_webhook(options): def create_webhook(options):
"""create_webhook """create_webhook
@ -106,8 +100,12 @@ def create_webhook(options):
if id_webhook: if id_webhook:
print('Success! The webhook ID is', id_webhook) print('Success! The webhook ID is', id_webhook)
print('Success! The webhook for the {} Trello board was successfully created.'.format( print(
options.trello_board_name)) 'Success! The webhook for the {} Trello board was successfully created.'.format(
options.trello_board_name
)
)
def main(): def main():
description = """ description = """
@ -120,28 +118,36 @@ at <https://zulip.com/integrations/doc/trello>.
""" """
parser = argparse.ArgumentParser(description=description) parser = argparse.ArgumentParser(description=description)
parser.add_argument('--trello-board-name', parser.add_argument('--trello-board-name', required=True, help='The Trello board name.')
required=True, parser.add_argument(
help='The Trello board name.') '--trello-board-id',
parser.add_argument('--trello-board-id', required=True,
required=True, help=('The Trello board short ID. Can usually be found ' 'in the URL of the Trello board.'),
help=('The Trello board short ID. Can usually be found ' )
'in the URL of the Trello board.')) parser.add_argument(
parser.add_argument('--trello-api-key', '--trello-api-key',
required=True, required=True,
help=('Visit https://trello.com/1/appkey/generate to generate ' help=(
'an APPLICATION_KEY (need to be logged into Trello).')) 'Visit https://trello.com/1/appkey/generate to generate '
parser.add_argument('--trello-token', 'an APPLICATION_KEY (need to be logged into Trello).'
required=True, ),
help=('Visit https://trello.com/1/appkey/generate and under ' )
'`Developer API Keys`, click on `Token` and generate ' parser.add_argument(
'a Trello access token.')) '--trello-token',
parser.add_argument('--zulip-webhook-url', required=True,
required=True, help=(
help='The webhook URL that Trello will query.') 'Visit https://trello.com/1/appkey/generate and under '
'`Developer API Keys`, click on `Token` and generate '
'a Trello access token.'
),
)
parser.add_argument(
'--zulip-webhook-url', required=True, help='The webhook URL that Trello will query.'
)
options = parser.parse_args() options = parser.parse_args()
create_webhook(options) create_webhook(options)
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View file

@ -67,37 +67,34 @@ Make sure to go the application you created and click "create my
access token" as well. Fill in the values displayed. access token" as well. Fill in the values displayed.
""" """
def write_config(config: ConfigParser, configfile_path: str) -> None: def write_config(config: ConfigParser, configfile_path: str) -> None:
with open(configfile_path, 'w') as configfile: with open(configfile_path, 'w') as configfile:
config.write(configfile) config.write(configfile)
parser = zulip.add_default_arguments(argparse.ArgumentParser("Fetch tweets from Twitter.")) parser = zulip.add_default_arguments(argparse.ArgumentParser("Fetch tweets from Twitter."))
parser.add_argument('--instructions', parser.add_argument(
action='store_true', '--instructions',
help='Show instructions for the twitter bot setup and exit' action='store_true',
) help='Show instructions for the twitter bot setup and exit',
parser.add_argument('--limit-tweets', )
default=15, parser.add_argument(
type=int, '--limit-tweets', default=15, type=int, help='Maximum number of tweets to send at once'
help='Maximum number of tweets to send at once') )
parser.add_argument('--search', parser.add_argument('--search', dest='search_terms', help='Terms to search on', action='store')
dest='search_terms', parser.add_argument(
help='Terms to search on', '--stream',
action='store') dest='stream',
parser.add_argument('--stream', help='The stream to which to send tweets',
dest='stream', default="twitter",
help='The stream to which to send tweets', action='store',
default="twitter", )
action='store') parser.add_argument(
parser.add_argument('--twitter-name', '--twitter-name', dest='twitter_name', help='Twitter username to poll new tweets from"'
dest='twitter_name', )
help='Twitter username to poll new tweets from"') parser.add_argument('--excluded-terms', dest='excluded_terms', help='Terms to exclude tweets on')
parser.add_argument('--excluded-terms', parser.add_argument('--excluded-users', dest='excluded_users', help='Users to exclude tweets on')
dest='excluded_terms',
help='Terms to exclude tweets on')
parser.add_argument('--excluded-users',
dest='excluded_users',
help='Users to exclude tweets on')
opts = parser.parse_args() opts = parser.parse_args()
@ -150,18 +147,22 @@ try:
except ImportError: except ImportError:
parser.error("Please install python-twitter") parser.error("Please install python-twitter")
api = twitter.Api(consumer_key=consumer_key, api = twitter.Api(
consumer_secret=consumer_secret, consumer_key=consumer_key,
access_token_key=access_token_key, consumer_secret=consumer_secret,
access_token_secret=access_token_secret) access_token_key=access_token_key,
access_token_secret=access_token_secret,
)
user = api.VerifyCredentials() user = api.VerifyCredentials()
if not user.id: if not user.id:
print("Unable to log in to twitter with supplied credentials. Please double-check and try again") print(
"Unable to log in to twitter with supplied credentials. Please double-check and try again"
)
sys.exit(1) sys.exit(1)
client = zulip.init_from_options(opts, client=client_type+VERSION) client = zulip.init_from_options(opts, client=client_type + VERSION)
if opts.search_terms: if opts.search_terms:
search_query = " OR ".join(opts.search_terms.split(",")) search_query = " OR ".join(opts.search_terms.split(","))
@ -190,7 +191,7 @@ if opts.excluded_users:
else: else:
excluded_users = [] excluded_users = []
for status in statuses[::-1][:opts.limit_tweets]: for status in statuses[::-1][: opts.limit_tweets]:
# Check if the tweet is from an excluded user # Check if the tweet is from an excluded user
exclude = False exclude = False
for user in excluded_users: for user in excluded_users:
@ -237,12 +238,7 @@ for status in statuses[::-1][:opts.limit_tweets]:
elif opts.twitter_name: elif opts.twitter_name:
subject = composed subject = composed
message = { message = {"type": "stream", "to": [opts.stream], "subject": subject, "content": url}
"type": "stream",
"to": [opts.stream],
"subject": subject,
"content": url
}
ret = client.send_message(message) ret = client.send_message(message)

View file

@ -13,36 +13,25 @@ import zephyr
import zulip import zulip
parser = optparse.OptionParser() parser = optparse.OptionParser()
parser.add_option('--verbose', parser.add_option('--verbose', dest='verbose', default=False, action='store_true')
dest='verbose', parser.add_option('--site', dest='site', default=None, action='store')
default=False, parser.add_option('--sharded', default=False, action='store_true')
action='store_true')
parser.add_option('--site',
dest='site',
default=None,
action='store')
parser.add_option('--sharded',
default=False,
action='store_true')
(options, args) = parser.parse_args() (options, args) = parser.parse_args()
mit_user = 'tabbott/extra@ATHENA.MIT.EDU' mit_user = 'tabbott/extra@ATHENA.MIT.EDU'
zulip_client = zulip.Client( zulip_client = zulip.Client(verbose=True, client="ZulipMonitoring/0.1", site=options.site)
verbose=True,
client="ZulipMonitoring/0.1",
site=options.site)
# Configure logging # Configure logging
log_file = "/var/log/zulip/check-mirroring-log" log_file = "/var/log/zulip/check-mirroring-log"
log_format = "%(asctime)s: %(message)s" log_format = "%(asctime)s: %(message)s"
logging.basicConfig(format=log_format) logging.basicConfig(format=log_format)
formatter = logging.Formatter(log_format) formatter = logging.Formatter(log_format)
file_handler = logging.FileHandler(log_file) file_handler = logging.FileHandler(log_file)
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
logger.addHandler(file_handler) logger.addHandler(file_handler)
@ -75,13 +64,14 @@ if options.sharded:
for (stream, test) in test_streams: for (stream, test) in test_streams:
if stream == "message": if stream == "message":
continue continue
assert(hashlib.sha1(stream.encode("utf-8")).hexdigest().startswith(test)) assert hashlib.sha1(stream.encode("utf-8")).hexdigest().startswith(test)
else: else:
test_streams = [ test_streams = [
("message", "p"), ("message", "p"),
("tabbott-nagios-test", "a"), ("tabbott-nagios-test", "a"),
] ]
def print_status_and_exit(status: int) -> None: def print_status_and_exit(status: int) -> None:
# The output of this script is used by Nagios. Various outputs, # The output of this script is used by Nagios. Various outputs,
@ -91,6 +81,7 @@ def print_status_and_exit(status: int) -> None:
print(status) print(status)
sys.exit(status) sys.exit(status)
def send_zulip(message: Dict[str, str]) -> None: def send_zulip(message: Dict[str, str]) -> None:
result = zulip_client.send_message(message) result = zulip_client.send_message(message)
if result["result"] != "success": if result["result"] != "success":
@ -99,11 +90,16 @@ def send_zulip(message: Dict[str, str]) -> None:
logger.error(str(result)) logger.error(str(result))
print_status_and_exit(1) print_status_and_exit(1)
# Returns True if and only if we "Detected server failure" sending the zephyr. # Returns True if and only if we "Detected server failure" sending the zephyr.
def send_zephyr(zwrite_args: List[str], content: str) -> bool: def send_zephyr(zwrite_args: List[str], content: str) -> bool:
p = subprocess.Popen(zwrite_args, stdin=subprocess.PIPE, p = subprocess.Popen(
stdout=subprocess.PIPE, stderr=subprocess.PIPE, zwrite_args,
universal_newlines=True) stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
stdout, stderr = p.communicate(input=content) stdout, stderr = p.communicate(input=content)
if p.returncode != 0: if p.returncode != 0:
if "Detected server failure while receiving acknowledgement for" in stdout: if "Detected server failure while receiving acknowledgement for" in stdout:
@ -116,6 +112,7 @@ def send_zephyr(zwrite_args: List[str], content: str) -> bool:
print_status_and_exit(1) print_status_and_exit(1)
return False return False
# Subscribe to Zulip # Subscribe to Zulip
try: try:
res = zulip_client.register(event_types=["message"]) res = zulip_client.register(event_types=["message"])
@ -164,6 +161,8 @@ if not actually_subscribed:
# Prepare keys # Prepare keys
zhkeys = {} # type: Dict[str, Tuple[str, str]] zhkeys = {} # type: Dict[str, Tuple[str, str]]
hzkeys = {} # type: Dict[str, Tuple[str, str]] hzkeys = {} # type: Dict[str, Tuple[str, str]]
def gen_key(key_dict: Dict[str, Tuple[str, str]]) -> str: def gen_key(key_dict: Dict[str, Tuple[str, str]]) -> str:
bits = str(random.getrandbits(32)) bits = str(random.getrandbits(32))
while bits in key_dict: while bits in key_dict:
@ -171,10 +170,12 @@ def gen_key(key_dict: Dict[str, Tuple[str, str]]) -> str:
bits = str(random.getrandbits(32)) bits = str(random.getrandbits(32))
return bits return bits
def gen_keys(key_dict: Dict[str, Tuple[str, str]]) -> None: def gen_keys(key_dict: Dict[str, Tuple[str, str]]) -> None:
for (stream, test) in test_streams: for (stream, test) in test_streams:
key_dict[gen_key(key_dict)] = (stream, test) key_dict[gen_key(key_dict)] = (stream, test)
gen_keys(zhkeys) gen_keys(zhkeys)
gen_keys(hzkeys) gen_keys(hzkeys)
@ -196,6 +197,7 @@ def receive_zephyrs() -> None:
continue continue
notices.append(notice) notices.append(notice)
logger.info("Starting sending messages!") logger.info("Starting sending messages!")
# Send zephyrs # Send zephyrs
zsig = "Timothy Good Abbott" zsig = "Timothy Good Abbott"
@ -212,12 +214,15 @@ for key, (stream, test) in zhkeys.items():
zhkeys[new_key] = value zhkeys[new_key] = value
server_failure_again = send_zephyr(zwrite_args, str(new_key)) server_failure_again = send_zephyr(zwrite_args, str(new_key))
if server_failure_again: if server_failure_again:
logging.error("Zephyr server failure twice in a row on keys %s and %s! Aborting." % logging.error(
(key, new_key)) "Zephyr server failure twice in a row on keys %s and %s! Aborting."
% (key, new_key)
)
print_status_and_exit(1) print_status_and_exit(1)
else: else:
logging.warning("Replaced key %s with %s due to Zephyr server failure." % logging.warning(
(key, new_key)) "Replaced key %s with %s due to Zephyr server failure." % (key, new_key)
)
receive_zephyrs() receive_zephyrs()
receive_zephyrs() receive_zephyrs()
@ -226,18 +231,22 @@ logger.info("Sent Zephyr messages!")
# Send Zulips # Send Zulips
for key, (stream, test) in hzkeys.items(): for key, (stream, test) in hzkeys.items():
if stream == "message": if stream == "message":
send_zulip({ send_zulip(
"type": "private", {
"content": str(key), "type": "private",
"to": zulip_client.email, "content": str(key),
}) "to": zulip_client.email,
}
)
else: else:
send_zulip({ send_zulip(
"type": "stream", {
"subject": "test", "type": "stream",
"content": str(key), "subject": "test",
"to": stream, "content": str(key),
}) "to": stream,
}
)
receive_zephyrs() receive_zephyrs()
logger.info("Sent Zulip messages!") logger.info("Sent Zulip messages!")
@ -265,6 +274,8 @@ receive_zephyrs()
logger.info("Finished receiving Zephyr messages!") logger.info("Finished receiving Zephyr messages!")
all_keys = set(list(zhkeys.keys()) + list(hzkeys.keys())) all_keys = set(list(zhkeys.keys()) + list(hzkeys.keys()))
def process_keys(content_list: List[str]) -> Tuple[Dict[str, int], Set[str], Set[str], bool, bool]: def process_keys(content_list: List[str]) -> Tuple[Dict[str, int], Set[str], Set[str], bool, bool]:
# Start by filtering out any keys that might have come from # Start by filtering out any keys that might have come from
@ -281,6 +292,7 @@ def process_keys(content_list: List[str]) -> Tuple[Dict[str, int], Set[str], Set
success = all(val == 1 for val in key_counts.values()) success = all(val == 1 for val in key_counts.values())
return key_counts, z_missing, h_missing, duplicates, success return key_counts, z_missing, h_missing, duplicates, success
# The h_foo variables are about the messages we _received_ in Zulip # The h_foo variables are about the messages we _received_ in Zulip
# The z_foo variables are about the messages we _received_ in Zephyr # The z_foo variables are about the messages we _received_ in Zephyr
h_contents = [message["content"] for message in messages] h_contents = [message["content"] for message in messages]
@ -302,12 +314,16 @@ for key in all_keys:
continue continue
if key in zhkeys: if key in zhkeys:
(stream, test) = zhkeys[key] (stream, test) = zhkeys[key]
logger.warning("%10s: z got %s, h got %s. Sent via Zephyr(%s): class %s" % logger.warning(
(key, z_key_counts[key], h_key_counts[key], test, stream)) "%10s: z got %s, h got %s. Sent via Zephyr(%s): class %s"
% (key, z_key_counts[key], h_key_counts[key], test, stream)
)
if key in hzkeys: if key in hzkeys:
(stream, test) = hzkeys[key] (stream, test) = hzkeys[key]
logger.warning("%10s: z got %s. h got %s. Sent via Zulip(%s): class %s" % logger.warning(
(key, z_key_counts[key], h_key_counts[key], test, stream)) "%10s: z got %s. h got %s. Sent via Zulip(%s): class %s"
% (key, z_key_counts[key], h_key_counts[key], test, stream)
)
logger.error("") logger.error("")
logger.error("Summary of specific problems:") logger.error("Summary of specific problems:")
@ -322,10 +338,14 @@ if z_duplicates:
if z_missing_z: if z_missing_z:
logger.error("zephyr: Didn't receive all the Zephyrs we sent on the Zephyr end!") logger.error("zephyr: Didn't receive all the Zephyrs we sent on the Zephyr end!")
logger.error("zephyr: This is probably an issue with check-mirroring sending or receiving Zephyrs.") logger.error(
"zephyr: This is probably an issue with check-mirroring sending or receiving Zephyrs."
)
if h_missing_h: if h_missing_h:
logger.error("zulip: Didn't receive all the Zulips we sent on the Zulip end!") logger.error("zulip: Didn't receive all the Zulips we sent on the Zulip end!")
logger.error("zulip: This is probably an issue with check-mirroring sending or receiving Zulips.") logger.error(
"zulip: This is probably an issue with check-mirroring sending or receiving Zulips."
)
if z_missing_h: if z_missing_h:
logger.error("zephyr: Didn't receive all the Zulips we sent on the Zephyr end!") logger.error("zephyr: Didn't receive all the Zulips we sent on the Zephyr end!")
if z_missing_h == h_missing_h: if z_missing_h == h_missing_h:

View file

@ -27,7 +27,9 @@ session_path = "/home/zulip/zephyr_sessions/%s" % (program_name,)
try: try:
if "--forward-mail-zephyrs" in open(supervisor_path).read(): if "--forward-mail-zephyrs" in open(supervisor_path).read():
template_data = template_data.replace("--use-sessions", "--use-sessions --forward-mail-zephyrs") template_data = template_data.replace(
"--use-sessions", "--use-sessions --forward-mail-zephyrs"
)
except Exception: except Exception:
pass pass
open(supervisor_path, "w").write(template_data.replace("USERNAME", short_user)) open(supervisor_path, "w").write(template_data.replace("USERNAME", short_user))

View file

@ -17,9 +17,22 @@ def write_public_streams() -> None:
# Zephyr class names are canonicalized by first applying NFKC # Zephyr class names are canonicalized by first applying NFKC
# normalization and then lower-casing server-side # normalization and then lower-casing server-side
canonical_cls = unicodedata.normalize("NFKC", stream_name).lower() canonical_cls = unicodedata.normalize("NFKC", stream_name).lower()
if canonical_cls in ['security', 'login', 'network', 'ops', 'user_locate', if canonical_cls in [
'mit', 'moof', 'wsmonitor', 'wg_ctl', 'winlogger', 'security',
'hm_ctl', 'hm_stat', 'zephyr_admin', 'zephyr_ctl']: 'login',
'network',
'ops',
'user_locate',
'mit',
'moof',
'wsmonitor',
'wg_ctl',
'winlogger',
'hm_ctl',
'hm_stat',
'zephyr_admin',
'zephyr_ctl',
]:
# These zephyr classes cannot be subscribed to by us, due # These zephyr classes cannot be subscribed to by us, due
# to MIT's Zephyr access control settings # to MIT's Zephyr access control settings
continue continue
@ -30,6 +43,7 @@ def write_public_streams() -> None:
f.write(json.dumps(list(public_streams)) + "\n") f.write(json.dumps(list(public_streams)) + "\n")
os.rename("/home/zulip/public_streams.tmp", "/home/zulip/public_streams") os.rename("/home/zulip/public_streams.tmp", "/home/zulip/public_streams")
if __name__ == "__main__": if __name__ == "__main__":
log_file = "/home/zulip/sync_public_streams.log" log_file = "/home/zulip/sync_public_streams.log"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -83,9 +97,7 @@ if __name__ == "__main__":
last_event_id = max(last_event_id, event["id"]) last_event_id = max(last_event_id, event["id"])
if event["type"] == "stream": if event["type"] == "stream":
if event["op"] == "create": if event["op"] == "create":
stream_names.update( stream_names.update(stream["name"] for stream in event["streams"])
stream["name"] for stream in event["streams"]
)
write_public_streams() write_public_streams()
elif event["op"] == "delete": elif event["op"] == "delete":
stream_names.difference_update( stream_names.difference_update(

View file

@ -19,6 +19,7 @@ def die(signal: int, frame: FrameType) -> None:
# We actually want to exit, so run os._exit (so as not to be caught and restarted) # We actually want to exit, so run os._exit (so as not to be caught and restarted)
os._exit(1) os._exit(1)
signal.signal(signal.SIGINT, die) signal.signal(signal.SIGINT, die)
from zulip import RandomExponentialBackoff from zulip import RandomExponentialBackoff
@ -36,12 +37,14 @@ if options.forward_class_messages and not options.noshard:
if options.on_startup_command is not None: if options.on_startup_command is not None:
subprocess.call([options.on_startup_command]) subprocess.call([options.on_startup_command])
from zerver.lib.parallel import run_parallel from zerver.lib.parallel import run_parallel
print("Starting parallel zephyr class mirroring bot") print("Starting parallel zephyr class mirroring bot")
jobs = list("0123456789abcdef") jobs = list("0123456789abcdef")
def run_job(shard: str) -> int: def run_job(shard: str) -> int:
subprocess.call(args + ["--shard=%s" % (shard,)]) subprocess.call(args + ["--shard=%s" % (shard,)])
return 0 return 0
for (status, job) in run_parallel(run_job, jobs, threads=16): for (status, job) in run_parallel(run_job, jobs, threads=16):
print("A mirroring shard died!") print("A mirroring shard died!")
sys.exit(0) sys.exit(0)

View file

@ -22,12 +22,16 @@ from zulip import RandomExponentialBackoff
DEFAULT_SITE = "https://api.zulip.com" DEFAULT_SITE = "https://api.zulip.com"
class States: class States:
Startup, ZulipToZephyr, ZephyrToZulip, ChildSending = list(range(4)) Startup, ZulipToZephyr, ZephyrToZulip, ChildSending = list(range(4))
CURRENT_STATE = States.Startup CURRENT_STATE = States.Startup
logger: logging.Logger logger: logging.Logger
def to_zulip_username(zephyr_username: str) -> str: def to_zulip_username(zephyr_username: str) -> str:
if "@" in zephyr_username: if "@" in zephyr_username:
(user, realm) = zephyr_username.split("@") (user, realm) = zephyr_username.split("@")
@ -40,6 +44,7 @@ def to_zulip_username(zephyr_username: str) -> str:
return user.lower() + "@mit.edu" return user.lower() + "@mit.edu"
return user.lower() + "|" + realm.upper() + "@mit.edu" return user.lower() + "|" + realm.upper() + "@mit.edu"
def to_zephyr_username(zulip_username: str) -> str: def to_zephyr_username(zulip_username: str) -> str:
(user, realm) = zulip_username.split("@") (user, realm) = zulip_username.split("@")
if "|" not in user: if "|" not in user:
@ -52,6 +57,7 @@ def to_zephyr_username(zulip_username: str) -> str:
raise Exception("Could not parse Zephyr realm for cross-realm user %s" % (zulip_username,)) raise Exception("Could not parse Zephyr realm for cross-realm user %s" % (zulip_username,))
return match_user.group(1).lower() + "@" + match_user.group(2).upper() return match_user.group(1).lower() + "@" + match_user.group(2).upper()
# Checks whether the pair of adjacent lines would have been # Checks whether the pair of adjacent lines would have been
# linewrapped together, had they been intended to be parts of the same # linewrapped together, had they been intended to be parts of the same
# paragraph. Our check is whether if you move the first word on the # paragraph. Our check is whether if you move the first word on the
@ -70,6 +76,7 @@ def different_paragraph(line: str, next_line: str) -> bool:
or len(line) < len(words[0]) or len(line) < len(words[0])
) )
# Linewrapping algorithm based on: # Linewrapping algorithm based on:
# http://gcbenison.wordpress.com/2011/07/03/a-program-to-intelligently-remove-carriage-returns-so-you-can-paste-text-without-having-it-look-awful/ #ignorelongline # http://gcbenison.wordpress.com/2011/07/03/a-program-to-intelligently-remove-carriage-returns-so-you-can-paste-text-without-having-it-look-awful/ #ignorelongline
def unwrap_lines(body: str) -> str: def unwrap_lines(body: str) -> str:
@ -78,9 +85,8 @@ def unwrap_lines(body: str) -> str:
previous_line = lines[0] previous_line = lines[0]
for line in lines[1:]: for line in lines[1:]:
line = line.rstrip() line = line.rstrip()
if ( if re.match(r'^\W', line, flags=re.UNICODE) and re.match(
re.match(r'^\W', line, flags=re.UNICODE) r'^\W', previous_line, flags=re.UNICODE
and re.match(r'^\W', previous_line, flags=re.UNICODE)
): ):
result += previous_line + "\n" result += previous_line + "\n"
elif ( elif (
@ -99,6 +105,7 @@ def unwrap_lines(body: str) -> str:
result += previous_line result += previous_line
return result return result
class ZephyrDict(TypedDict, total=False): class ZephyrDict(TypedDict, total=False):
type: Literal["private", "stream"] type: Literal["private", "stream"]
time: str time: str
@ -109,6 +116,7 @@ class ZephyrDict(TypedDict, total=False):
content: str content: str
zsig: str zsig: str
def send_zulip(zeph: ZephyrDict) -> Dict[str, Any]: def send_zulip(zeph: ZephyrDict) -> Dict[str, Any]:
message: Dict[str, Any] message: Dict[str, Any]
message = {} message = {}
@ -142,15 +150,20 @@ def send_zulip(zeph: ZephyrDict) -> Dict[str, Any]:
return zulip_client.send_message(message) return zulip_client.send_message(message)
def send_error_zulip(error_msg: str) -> None: def send_error_zulip(error_msg: str) -> None:
message = {"type": "private", message = {
"sender": zulip_account_email, "type": "private",
"to": zulip_account_email, "sender": zulip_account_email,
"content": error_msg, "to": zulip_account_email,
} "content": error_msg,
}
zulip_client.send_message(message) zulip_client.send_message(message)
current_zephyr_subs = set() current_zephyr_subs = set()
def zephyr_bulk_subscribe(subs: List[Tuple[str, str, str]]) -> None: def zephyr_bulk_subscribe(subs: List[Tuple[str, str, str]]) -> None:
try: try:
zephyr._z.subAll(subs) zephyr._z.subAll(subs)
@ -186,6 +199,7 @@ def zephyr_bulk_subscribe(subs: List[Tuple[str, str, str]]) -> None:
else: else:
current_zephyr_subs.add(cls) current_zephyr_subs.add(cls)
def update_subscriptions() -> None: def update_subscriptions() -> None:
try: try:
f = open(options.stream_file_path) f = open(options.stream_file_path)
@ -198,10 +212,9 @@ def update_subscriptions() -> None:
classes_to_subscribe = set() classes_to_subscribe = set()
for stream in public_streams: for stream in public_streams:
zephyr_class = stream zephyr_class = stream
if ( if options.shard is not None and not hashlib.sha1(
options.shard is not None zephyr_class.encode("utf-8")
and not hashlib.sha1(zephyr_class.encode("utf-8")).hexdigest().startswith(options.shard) ).hexdigest().startswith(options.shard):
):
# This stream is being handled by a different zephyr_mirror job. # This stream is being handled by a different zephyr_mirror job.
continue continue
if zephyr_class in current_zephyr_subs: if zephyr_class in current_zephyr_subs:
@ -211,6 +224,7 @@ def update_subscriptions() -> None:
if len(classes_to_subscribe) > 0: if len(classes_to_subscribe) > 0:
zephyr_bulk_subscribe(list(classes_to_subscribe)) zephyr_bulk_subscribe(list(classes_to_subscribe))
def maybe_kill_child() -> None: def maybe_kill_child() -> None:
try: try:
if child_pid is not None: if child_pid is not None:
@ -219,10 +233,14 @@ def maybe_kill_child() -> None:
# We don't care if the child process no longer exists, so just log the error # We don't care if the child process no longer exists, so just log the error
logger.exception("") logger.exception("")
def maybe_restart_mirroring_script() -> None: def maybe_restart_mirroring_script() -> None:
if os.stat(os.path.join(options.stamp_path, "stamps", "restart_stamp")).st_mtime > start_time or ( if os.stat(
os.path.join(options.stamp_path, "stamps", "restart_stamp")
).st_mtime > start_time or (
(options.user == "tabbott" or options.user == "tabbott/extra") (options.user == "tabbott" or options.user == "tabbott/extra")
and os.stat(os.path.join(options.stamp_path, "stamps", "tabbott_stamp")).st_mtime > start_time and os.stat(os.path.join(options.stamp_path, "stamps", "tabbott_stamp")).st_mtime
> start_time
): ):
logger.warning("") logger.warning("")
logger.warning("zephyr mirroring script has been updated; restarting...") logger.warning("zephyr mirroring script has been updated; restarting...")
@ -244,6 +262,7 @@ def maybe_restart_mirroring_script() -> None:
backoff.fail() backoff.fail()
raise Exception("Failed to reload too many times, aborting!") raise Exception("Failed to reload too many times, aborting!")
def process_loop(log: Optional[IO[str]]) -> NoReturn: def process_loop(log: Optional[IO[str]]) -> NoReturn:
restart_check_count = 0 restart_check_count = 0
last_check_time = time.time() last_check_time = time.time()
@ -287,6 +306,7 @@ def process_loop(log: Optional[IO[str]]) -> NoReturn:
except Exception: except Exception:
logger.exception("Error updating subscriptions from Zulip:") logger.exception("Error updating subscriptions from Zulip:")
def parse_zephyr_body(zephyr_data: str, notice_format: str) -> Tuple[str, str]: def parse_zephyr_body(zephyr_data: str, notice_format: str) -> Tuple[str, str]:
try: try:
(zsig, body) = zephyr_data.split("\x00", 1) (zsig, body) = zephyr_data.split("\x00", 1)
@ -298,13 +318,19 @@ def parse_zephyr_body(zephyr_data: str, notice_format: str) -> Tuple[str, str]:
fields = body.split('\x00') fields = body.split('\x00')
if len(fields) == 5: if len(fields) == 5:
body = 'New transaction [%s] entered in %s\nFrom: %s (%s)\nSubject: %s' % ( body = 'New transaction [%s] entered in %s\nFrom: %s (%s)\nSubject: %s' % (
fields[0], fields[1], fields[2], fields[4], fields[3]) fields[0],
fields[1],
fields[2],
fields[4],
fields[3],
)
except ValueError: except ValueError:
(zsig, body) = ("", zephyr_data) (zsig, body) = ("", zephyr_data)
# Clean body of any null characters, since they're invalid in our protocol. # Clean body of any null characters, since they're invalid in our protocol.
body = body.replace('\x00', '') body = body.replace('\x00', '')
return (zsig, body) return (zsig, body)
def parse_crypt_table(zephyr_class: str, instance: str) -> Optional[str]: def parse_crypt_table(zephyr_class: str, instance: str) -> Optional[str]:
try: try:
crypt_table = open(os.path.join(os.environ["HOME"], ".crypt-table")) crypt_table = open(os.path.join(os.environ["HOME"], ".crypt-table"))
@ -315,17 +341,23 @@ def parse_crypt_table(zephyr_class: str, instance: str) -> Optional[str]:
if line.strip() == "": if line.strip() == "":
# Ignore blank lines # Ignore blank lines
continue continue
match = re.match(r"^crypt-(?P<class>\S+):\s+((?P<algorithm>(AES|DES)):\s+)?(?P<keypath>\S+)$", line) match = re.match(
r"^crypt-(?P<class>\S+):\s+((?P<algorithm>(AES|DES)):\s+)?(?P<keypath>\S+)$", line
)
if match is None: if match is None:
# Malformed crypt_table line # Malformed crypt_table line
logger.debug("Invalid crypt_table line!") logger.debug("Invalid crypt_table line!")
continue continue
groups = match.groupdict() groups = match.groupdict()
if groups['class'].lower() == zephyr_class and 'keypath' in groups and \ if (
groups.get("algorithm") == "AES": groups['class'].lower() == zephyr_class
and 'keypath' in groups
and groups.get("algorithm") == "AES"
):
return groups["keypath"] return groups["keypath"]
return None return None
def decrypt_zephyr(zephyr_class: str, instance: str, body: str) -> str: def decrypt_zephyr(zephyr_class: str, instance: str, body: str) -> str:
keypath = parse_crypt_table(zephyr_class, instance) keypath = parse_crypt_table(zephyr_class, instance)
if keypath is None: if keypath is None:
@ -337,27 +369,32 @@ def decrypt_zephyr(zephyr_class: str, instance: str, body: str) -> str:
signal.signal(signal.SIGCHLD, signal.SIG_DFL) signal.signal(signal.SIGCHLD, signal.SIG_DFL)
# decrypt the message! # decrypt the message!
p = subprocess.Popen(["gpg", p = subprocess.Popen(
"--decrypt", [
"--no-options", "gpg",
"--no-default-keyring", "--decrypt",
"--keyring=/dev/null", "--no-options",
"--secret-keyring=/dev/null", "--no-default-keyring",
"--batch", "--keyring=/dev/null",
"--quiet", "--secret-keyring=/dev/null",
"--no-use-agent", "--batch",
"--passphrase-file", "--quiet",
keypath], "--no-use-agent",
stdin=subprocess.PIPE, "--passphrase-file",
stdout=subprocess.PIPE, keypath,
stderr=subprocess.PIPE, ],
universal_newlines=True, stdin=subprocess.PIPE,
errors="replace") stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
errors="replace",
)
decrypted, _ = p.communicate(input=body) decrypted, _ = p.communicate(input=body)
# Restore our ignoring signals # Restore our ignoring signals
signal.signal(signal.SIGCHLD, signal.SIG_IGN) signal.signal(signal.SIGCHLD, signal.SIG_IGN)
return decrypted return decrypted
def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None: def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
assert notice.sender is not None assert notice.sender is not None
(zsig, body) = parse_zephyr_body(notice.message, notice.format) (zsig, body) = parse_zephyr_body(notice.message, notice.format)
@ -382,8 +419,7 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
if is_personal and not options.forward_personals: if is_personal and not options.forward_personals:
return return
if (zephyr_class not in current_zephyr_subs) and not is_personal: if (zephyr_class not in current_zephyr_subs) and not is_personal:
logger.debug("Skipping ... %s/%s/%s" % logger.debug("Skipping ... %s/%s/%s" % (zephyr_class, notice.instance, is_personal))
(zephyr_class, notice.instance, is_personal))
return return
if notice.format.startswith("Zephyr error: See") or notice.format.endswith("@(@color(blue))"): if notice.format.startswith("Zephyr error: See") or notice.format.endswith("@(@color(blue))"):
logger.debug("Skipping message we got from Zulip!") logger.debug("Skipping message we got from Zulip!")
@ -401,20 +437,27 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
if body.startswith("CC:"): if body.startswith("CC:"):
is_huddle = True is_huddle = True
# Map "CC: user1 user2" => "user1@mit.edu, user2@mit.edu" # Map "CC: user1 user2" => "user1@mit.edu, user2@mit.edu"
huddle_recipients = [to_zulip_username(x.strip()) for x in huddle_recipients = [
body.split("\n")[0][4:].split()] to_zulip_username(x.strip()) for x in body.split("\n")[0][4:].split()
]
if notice.sender not in huddle_recipients: if notice.sender not in huddle_recipients:
huddle_recipients.append(to_zulip_username(notice.sender)) huddle_recipients.append(to_zulip_username(notice.sender))
body = body.split("\n", 1)[1] body = body.split("\n", 1)[1]
if options.forward_class_messages and notice.opcode is not None and notice.opcode.lower() == "crypt": if (
options.forward_class_messages
and notice.opcode is not None
and notice.opcode.lower() == "crypt"
):
body = decrypt_zephyr(zephyr_class, notice.instance.lower(), body) body = decrypt_zephyr(zephyr_class, notice.instance.lower(), body)
zeph: ZephyrDict zeph: ZephyrDict
zeph = {'time': str(notice.time), zeph = {
'sender': notice.sender, 'time': str(notice.time),
'zsig': zsig, # logged here but not used by app 'sender': notice.sender,
'content': body} 'zsig': zsig, # logged here but not used by app
'content': body,
}
if is_huddle: if is_huddle:
zeph['type'] = 'private' zeph['type'] = 'private'
zeph['recipient'] = huddle_recipients zeph['recipient'] = huddle_recipients
@ -442,8 +485,9 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
heading = "" heading = ""
zeph["content"] = heading + zeph["content"] zeph["content"] = heading + zeph["content"]
logger.info("Received a message on %s/%s from %s..." % logger.info(
(zephyr_class, notice.instance, notice.sender)) "Received a message on %s/%s from %s..." % (zephyr_class, notice.instance, notice.sender)
)
if log is not None: if log is not None:
log.write(json.dumps(zeph) + '\n') log.write(json.dumps(zeph) + '\n')
log.flush() log.flush()
@ -461,11 +505,13 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
finally: finally:
os._exit(0) os._exit(0)
def quit_failed_initialization(message: str) -> str: def quit_failed_initialization(message: str) -> str:
logger.error(message) logger.error(message)
maybe_kill_child() maybe_kill_child()
sys.exit(1) sys.exit(1)
def zephyr_init_autoretry() -> None: def zephyr_init_autoretry() -> None:
backoff = zulip.RandomExponentialBackoff() backoff = zulip.RandomExponentialBackoff()
while backoff.keep_going(): while backoff.keep_going():
@ -481,6 +527,7 @@ def zephyr_init_autoretry() -> None:
quit_failed_initialization("Could not initialize Zephyr library, quitting!") quit_failed_initialization("Could not initialize Zephyr library, quitting!")
def zephyr_load_session_autoretry(session_path: str) -> None: def zephyr_load_session_autoretry(session_path: str) -> None:
backoff = zulip.RandomExponentialBackoff() backoff = zulip.RandomExponentialBackoff()
while backoff.keep_going(): while backoff.keep_going():
@ -497,6 +544,7 @@ def zephyr_load_session_autoretry(session_path: str) -> None:
quit_failed_initialization("Could not load saved Zephyr session, quitting!") quit_failed_initialization("Could not load saved Zephyr session, quitting!")
def zephyr_subscribe_autoretry(sub: Tuple[str, str, str]) -> None: def zephyr_subscribe_autoretry(sub: Tuple[str, str, str]) -> None:
backoff = zulip.RandomExponentialBackoff() backoff = zulip.RandomExponentialBackoff()
while backoff.keep_going(): while backoff.keep_going():
@ -512,6 +560,7 @@ def zephyr_subscribe_autoretry(sub: Tuple[str, str, str]) -> None:
quit_failed_initialization("Could not subscribe to personals, quitting!") quit_failed_initialization("Could not subscribe to personals, quitting!")
def zephyr_to_zulip(options: optparse.Values) -> None: def zephyr_to_zulip(options: optparse.Values) -> None:
if options.use_sessions and os.path.exists(options.session_path): if options.use_sessions and os.path.exists(options.session_path):
logger.info("Loading old session") logger.info("Loading old session")
@ -542,9 +591,10 @@ def zephyr_to_zulip(options: optparse.Values) -> None:
zeph["stream"] = zeph["class"] zeph["stream"] = zeph["class"]
if "instance" in zeph: if "instance" in zeph:
zeph["subject"] = zeph["instance"] zeph["subject"] = zeph["instance"]
logger.info("sending saved message to %s from %s..." % logger.info(
(zeph.get('stream', zeph.get('recipient')), "sending saved message to %s from %s..."
zeph['sender'])) % (zeph.get('stream', zeph.get('recipient')), zeph['sender'])
)
send_zulip(zeph) send_zulip(zeph)
except Exception: except Exception:
logger.exception("Could not send saved zephyr:") logger.exception("Could not send saved zephyr:")
@ -558,55 +608,75 @@ def zephyr_to_zulip(options: optparse.Values) -> None:
else: else:
process_loop(None) process_loop(None)
def send_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]: def send_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]:
p = subprocess.Popen(zwrite_args, stdin=subprocess.PIPE, p = subprocess.Popen(
stdout=subprocess.PIPE, stderr=subprocess.PIPE, zwrite_args,
universal_newlines=True) stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
stdout, stderr = p.communicate(input=content) stdout, stderr = p.communicate(input=content)
if p.returncode: if p.returncode:
logger.error("zwrite command '%s' failed with return code %d:" % ( logger.error(
" ".join(zwrite_args), p.returncode,)) "zwrite command '%s' failed with return code %d:"
% (
" ".join(zwrite_args),
p.returncode,
)
)
if stdout: if stdout:
logger.info("stdout: " + stdout) logger.info("stdout: " + stdout)
elif stderr: elif stderr:
logger.warning("zwrite command '%s' printed the following warning:" % ( logger.warning(
" ".join(zwrite_args),)) "zwrite command '%s' printed the following warning:" % (" ".join(zwrite_args),)
)
if stderr: if stderr:
logger.warning("stderr: " + stderr) logger.warning("stderr: " + stderr)
return (p.returncode, stderr) return (p.returncode, stderr)
def send_authed_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]: def send_authed_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]:
return send_zephyr(zwrite_args, content) return send_zephyr(zwrite_args, content)
def send_unauthed_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]: def send_unauthed_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]:
return send_zephyr(zwrite_args + ["-d"], content) return send_zephyr(zwrite_args + ["-d"], content)
def zcrypt_encrypt_content(zephyr_class: str, instance: str, content: str) -> Optional[str]: def zcrypt_encrypt_content(zephyr_class: str, instance: str, content: str) -> Optional[str]:
keypath = parse_crypt_table(zephyr_class, instance) keypath = parse_crypt_table(zephyr_class, instance)
if keypath is None: if keypath is None:
return None return None
# encrypt the message! # encrypt the message!
p = subprocess.Popen(["gpg", p = subprocess.Popen(
"--symmetric", [
"--no-options", "gpg",
"--no-default-keyring", "--symmetric",
"--keyring=/dev/null", "--no-options",
"--secret-keyring=/dev/null", "--no-default-keyring",
"--batch", "--keyring=/dev/null",
"--quiet", "--secret-keyring=/dev/null",
"--no-use-agent", "--batch",
"--armor", "--quiet",
"--cipher-algo", "AES", "--no-use-agent",
"--passphrase-file", "--armor",
keypath], "--cipher-algo",
stdin=subprocess.PIPE, "AES",
stdout=subprocess.PIPE, "--passphrase-file",
stderr=subprocess.PIPE, keypath,
universal_newlines=True) ],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
encrypted, _ = p.communicate(input=content) encrypted, _ = p.communicate(input=content)
return encrypted return encrypted
def forward_to_zephyr(message: Dict[str, Any]) -> None: def forward_to_zephyr(message: Dict[str, Any]) -> None:
# 'Any' can be of any type of text # 'Any' can be of any type of text
support_heading = "Hi there! This is an automated message from Zulip." support_heading = "Hi there! This is an automated message from Zulip."
@ -614,12 +684,20 @@ def forward_to_zephyr(message: Dict[str, Any]) -> None:
Feedback button or at support@zulip.com.""" Feedback button or at support@zulip.com."""
wrapper = textwrap.TextWrapper(break_long_words=False, break_on_hyphens=False) wrapper = textwrap.TextWrapper(break_long_words=False, break_on_hyphens=False)
wrapped_content = "\n".join("\n".join(wrapper.wrap(line)) wrapped_content = "\n".join(
for line in message["content"].replace("@", "@@").split("\n")) "\n".join(wrapper.wrap(line)) for line in message["content"].replace("@", "@@").split("\n")
)
zwrite_args = ["zwrite", "-n", "-s", message["sender_full_name"], zwrite_args = [
"-F", "Zephyr error: See http://zephyr.1ts.org/wiki/df", "zwrite",
"-x", "UTF-8"] "-n",
"-s",
message["sender_full_name"],
"-F",
"Zephyr error: See http://zephyr.1ts.org/wiki/df",
"-x",
"UTF-8",
]
# Hack to make ctl's fake username setup work :) # Hack to make ctl's fake username setup work :)
if message['type'] == "stream" and zulip_account_email == "ctl@mit.edu": if message['type'] == "stream" and zulip_account_email == "ctl@mit.edu":
@ -634,9 +712,8 @@ Feedback button or at support@zulip.com."""
# Forward messages sent to '(instance "WHITESPACE")' back to the # Forward messages sent to '(instance "WHITESPACE")' back to the
# appropriate WHITESPACE instance for bidirectional mirroring # appropriate WHITESPACE instance for bidirectional mirroring
instance = match_whitespace_instance.group(1) instance = match_whitespace_instance.group(1)
elif ( elif instance == "instance %s" % (zephyr_class,) or instance == "test instance %s" % (
instance == "instance %s" % (zephyr_class,) zephyr_class,
or instance == "test instance %s" % (zephyr_class,)
): ):
# Forward messages to e.g. -c -i white-magic back from the # Forward messages to e.g. -c -i white-magic back from the
# place we forward them to # place we forward them to
@ -663,15 +740,18 @@ Feedback button or at support@zulip.com."""
zwrite_args.extend(["-C"]) zwrite_args.extend(["-C"])
# We drop the @ATHENA.MIT.EDU here because otherwise the # We drop the @ATHENA.MIT.EDU here because otherwise the
# "CC: user1 user2 ..." output will be unnecessarily verbose. # "CC: user1 user2 ..." output will be unnecessarily verbose.
recipients = [to_zephyr_username(user["email"]).replace("@ATHENA.MIT.EDU", "") recipients = [
for user in message["display_recipient"]] to_zephyr_username(user["email"]).replace("@ATHENA.MIT.EDU", "")
for user in message["display_recipient"]
]
logger.info("Forwarding message to %s" % (recipients,)) logger.info("Forwarding message to %s" % (recipients,))
zwrite_args.extend(recipients) zwrite_args.extend(recipients)
if message.get("invite_only_stream"): if message.get("invite_only_stream"):
result = zcrypt_encrypt_content(zephyr_class, instance, wrapped_content) result = zcrypt_encrypt_content(zephyr_class, instance, wrapped_content)
if result is None: if result is None:
send_error_zulip("""%s send_error_zulip(
"""%s
Your Zulip-Zephyr mirror bot was unable to forward that last message \ Your Zulip-Zephyr mirror bot was unable to forward that last message \
from Zulip to Zephyr because you were sending to a zcrypted Zephyr \ from Zulip to Zephyr because you were sending to a zcrypted Zephyr \
@ -679,7 +759,9 @@ class and your mirroring bot does not have access to the relevant \
key (perhaps because your AFS tokens expired). That means that while \ key (perhaps because your AFS tokens expired). That means that while \
Zulip users (like you) received it, Zephyr users did not. Zulip users (like you) received it, Zephyr users did not.
%s""" % (support_heading, support_closing)) %s"""
% (support_heading, support_closing)
)
return return
# Proceed with sending a zcrypted message # Proceed with sending a zcrypted message
@ -687,22 +769,24 @@ Zulip users (like you) received it, Zephyr users did not.
zwrite_args.extend(["-O", "crypt"]) zwrite_args.extend(["-O", "crypt"])
if options.test_mode: if options.test_mode:
logger.debug("Would have forwarded: %s\n%s" % logger.debug("Would have forwarded: %s\n%s" % (zwrite_args, wrapped_content))
(zwrite_args, wrapped_content))
return return
(code, stderr) = send_authed_zephyr(zwrite_args, wrapped_content) (code, stderr) = send_authed_zephyr(zwrite_args, wrapped_content)
if code == 0 and stderr == "": if code == 0 and stderr == "":
return return
elif code == 0: elif code == 0:
send_error_zulip("""%s send_error_zulip(
"""%s
Your last message was successfully mirrored to zephyr, but zwrite \ Your last message was successfully mirrored to zephyr, but zwrite \
returned the following warning: returned the following warning:
%s %s
%s""" % (support_heading, stderr, support_closing)) %s"""
% (support_heading, stderr, support_closing)
)
return return
elif code != 0 and ( elif code != 0 and (
stderr.startswith("zwrite: Ticket expired while sending notice to ") stderr.startswith("zwrite: Ticket expired while sending notice to ")
@ -714,7 +798,8 @@ returned the following warning:
if code == 0: if code == 0:
if options.ignore_expired_tickets: if options.ignore_expired_tickets:
return return
send_error_zulip("""%s send_error_zulip(
"""%s
Your last message was forwarded from Zulip to Zephyr unauthenticated, \ Your last message was forwarded from Zulip to Zephyr unauthenticated, \
because your Kerberos tickets have expired. It was sent successfully, \ because your Kerberos tickets have expired. It was sent successfully, \
@ -722,13 +807,16 @@ but please renew your Kerberos tickets in the screen session where you \
are running the Zulip-Zephyr mirroring bot, so we can send \ are running the Zulip-Zephyr mirroring bot, so we can send \
authenticated Zephyr messages for you again. authenticated Zephyr messages for you again.
%s""" % (support_heading, support_closing)) %s"""
% (support_heading, support_closing)
)
return return
# zwrite failed and it wasn't because of expired tickets: This is # zwrite failed and it wasn't because of expired tickets: This is
# probably because the recipient isn't subscribed to personals, # probably because the recipient isn't subscribed to personals,
# but regardless, we should just notify the user. # but regardless, we should just notify the user.
send_error_zulip("""%s send_error_zulip(
"""%s
Your Zulip-Zephyr mirror bot was unable to forward that last message \ Your Zulip-Zephyr mirror bot was unable to forward that last message \
from Zulip to Zephyr. That means that while Zulip users (like you) \ from Zulip to Zephyr. That means that while Zulip users (like you) \
@ -736,20 +824,22 @@ received it, Zephyr users did not. The error message from zwrite was:
%s %s
%s""" % (support_heading, stderr, support_closing)) %s"""
% (support_heading, stderr, support_closing)
)
return return
def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None: def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None:
# The key string can be used to direct any type of text. # The key string can be used to direct any type of text.
if (message["sender_email"] == zulip_account_email): if message["sender_email"] == zulip_account_email:
if not ( if not (
(message["type"] == "stream") (message["type"] == "stream")
or ( or (
message["type"] == "private" message["type"] == "private"
and False and False
not in [ not in [
u["email"].lower().endswith("mit.edu") u["email"].lower().endswith("mit.edu") for u in message["display_recipient"]
for u in message["display_recipient"]
] ]
) )
): ):
@ -758,8 +848,9 @@ def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None:
return return
timestamp_now = int(time.time()) timestamp_now = int(time.time())
if float(message["timestamp"]) < timestamp_now - 15: if float(message["timestamp"]) < timestamp_now - 15:
logger.warning("Skipping out of order message: %s < %s" % logger.warning(
(message["timestamp"], timestamp_now)) "Skipping out of order message: %s < %s" % (message["timestamp"], timestamp_now)
)
return return
try: try:
forward_to_zephyr(message) forward_to_zephyr(message)
@ -768,6 +859,7 @@ def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None:
# whole process # whole process
logger.exception("Error forwarding message:") logger.exception("Error forwarding message:")
def zulip_to_zephyr(options: optparse.Values) -> NoReturn: def zulip_to_zephyr(options: optparse.Values) -> NoReturn:
# Sync messages from zulip to zephyr # Sync messages from zulip to zephyr
logger.info("Starting syncing messages.") logger.info("Starting syncing messages.")
@ -779,6 +871,7 @@ def zulip_to_zephyr(options: optparse.Values) -> NoReturn:
logger.exception("Error syncing messages:") logger.exception("Error syncing messages:")
backoff.fail() backoff.fail()
def subscribed_to_mail_messages() -> bool: def subscribed_to_mail_messages() -> bool:
# In case we have lost our AFS tokens and those won't be able to # In case we have lost our AFS tokens and those won't be able to
# parse the Zephyr subs file, first try reading in result of this # parse the Zephyr subs file, first try reading in result of this
@ -787,12 +880,13 @@ def subscribed_to_mail_messages() -> bool:
if stored_result is not None: if stored_result is not None:
return stored_result == "True" return stored_result == "True"
for (cls, instance, recipient) in parse_zephyr_subs(verbose=False): for (cls, instance, recipient) in parse_zephyr_subs(verbose=False):
if (cls.lower() == "mail" and instance.lower() == "inbox"): if cls.lower() == "mail" and instance.lower() == "inbox":
os.environ["HUMBUG_FORWARD_MAIL_ZEPHYRS"] = "True" os.environ["HUMBUG_FORWARD_MAIL_ZEPHYRS"] = "True"
return True return True
os.environ["HUMBUG_FORWARD_MAIL_ZEPHYRS"] = "False" os.environ["HUMBUG_FORWARD_MAIL_ZEPHYRS"] = "False"
return False return False
def add_zulip_subscriptions(verbose: bool) -> None: def add_zulip_subscriptions(verbose: bool) -> None:
zephyr_subscriptions = set() zephyr_subscriptions = set()
skipped = set() skipped = set()
@ -805,7 +899,14 @@ def add_zulip_subscriptions(verbose: bool) -> None:
# We don't support subscribing to (message, *) # We don't support subscribing to (message, *)
if instance == "*": if instance == "*":
if recipient == "*": if recipient == "*":
skipped.add((cls, instance, recipient, "subscribing to all of class message is not supported.")) skipped.add(
(
cls,
instance,
recipient,
"subscribing to all of class message is not supported.",
)
)
continue continue
# If you're on -i white-magic on zephyr, get on stream white-magic on zulip # If you're on -i white-magic on zephyr, get on stream white-magic on zulip
# instead of subscribing to stream "message" on zulip # instead of subscribing to stream "message" on zulip
@ -826,8 +927,10 @@ def add_zulip_subscriptions(verbose: bool) -> None:
zephyr_subscriptions.add(cls) zephyr_subscriptions.add(cls)
if len(zephyr_subscriptions) != 0: if len(zephyr_subscriptions) != 0:
res = zulip_client.add_subscriptions(list({"name": stream} for stream in zephyr_subscriptions), res = zulip_client.add_subscriptions(
authorization_errors_fatal=False) list({"name": stream} for stream in zephyr_subscriptions),
authorization_errors_fatal=False,
)
if res.get("result") != "success": if res.get("result") != "success":
logger.error("Error subscribing to streams:\n%s" % (res["msg"],)) logger.error("Error subscribing to streams:\n%s" % (res["msg"],))
return return
@ -839,9 +942,15 @@ def add_zulip_subscriptions(verbose: bool) -> None:
if already is not None and len(already) > 0: if already is not None and len(already) > 0:
logger.info("\nAlready subscribed to: %s" % (", ".join(list(already.values())[0]),)) logger.info("\nAlready subscribed to: %s" % (", ".join(list(already.values())[0]),))
if new is not None and len(new) > 0: if new is not None and len(new) > 0:
logger.info("\nSuccessfully subscribed to: %s" % (", ".join(list(new.values())[0]),)) logger.info(
"\nSuccessfully subscribed to: %s" % (", ".join(list(new.values())[0]),)
)
if unauthorized is not None and len(unauthorized) > 0: if unauthorized is not None and len(unauthorized) > 0:
logger.info("\n" + "\n".join(textwrap.wrap("""\ logger.info(
"\n"
+ "\n".join(
textwrap.wrap(
"""\
The following streams you have NOT been subscribed to, The following streams you have NOT been subscribed to,
because they have been configured in Zulip as invitation-only streams. because they have been configured in Zulip as invitation-only streams.
This was done at the request of users of these Zephyr classes, usually This was done at the request of users of these Zephyr classes, usually
@ -850,11 +959,19 @@ via zcrypt (in Zulip, we achieve the same privacy goals through invitation-only
If you wish to read these streams in Zulip, you need to contact the people who are If you wish to read these streams in Zulip, you need to contact the people who are
on these streams and already use Zulip. They can subscribe you to them via the on these streams and already use Zulip. They can subscribe you to them via the
"streams" page in the Zulip web interface: "streams" page in the Zulip web interface:
""")) + "\n\n %s" % (", ".join(unauthorized),)) """
)
)
+ "\n\n %s" % (", ".join(unauthorized),)
)
if len(skipped) > 0: if len(skipped) > 0:
if verbose: if verbose:
logger.info("\n" + "\n".join(textwrap.wrap("""\ logger.info(
"\n"
+ "\n".join(
textwrap.wrap(
"""\
You have some lines in ~/.zephyr.subs that could not be You have some lines in ~/.zephyr.subs that could not be
synced to your Zulip subscriptions because they do not synced to your Zulip subscriptions because they do not
use "*" as both the instance and recipient and not one of use "*" as both the instance and recipient and not one of
@ -863,7 +980,11 @@ Zulip has a mechanism for forwarding. Zulip does not
allow subscribing to only some subjects on a Zulip allow subscribing to only some subjects on a Zulip
stream, so this tool has not created a corresponding stream, so this tool has not created a corresponding
Zulip subscription to these lines in ~/.zephyr.subs: Zulip subscription to these lines in ~/.zephyr.subs:
""")) + "\n") """
)
)
+ "\n"
)
for (cls, instance, recipient, reason) in skipped: for (cls, instance, recipient, reason) in skipped:
if verbose: if verbose:
@ -873,15 +994,25 @@ Zulip subscription to these lines in ~/.zephyr.subs:
logger.info(" [%s,%s,%s]" % (cls, instance, recipient)) logger.info(" [%s,%s,%s]" % (cls, instance, recipient))
if len(skipped) > 0: if len(skipped) > 0:
if verbose: if verbose:
logger.info("\n" + "\n".join(textwrap.wrap("""\ logger.info(
"\n"
+ "\n".join(
textwrap.wrap(
"""\
If you wish to be subscribed to any Zulip streams related If you wish to be subscribed to any Zulip streams related
to these .zephyrs.subs lines, please do so via the Zulip to these .zephyrs.subs lines, please do so via the Zulip
web interface. web interface.
""")) + "\n") """
)
)
+ "\n"
)
def valid_stream_name(name: str) -> bool: def valid_stream_name(name: str) -> bool:
return name != "" return name != ""
def parse_zephyr_subs(verbose: bool = False) -> Set[Tuple[str, str, str]]: def parse_zephyr_subs(verbose: bool = False) -> Set[Tuple[str, str, str]]:
zephyr_subscriptions = set() # type: Set[Tuple[str, str, str]] zephyr_subscriptions = set() # type: Set[Tuple[str, str, str]]
subs_file = os.path.join(os.environ["HOME"], ".zephyr.subs") subs_file = os.path.join(os.environ["HOME"], ".zephyr.subs")
@ -910,6 +1041,7 @@ def parse_zephyr_subs(verbose: bool = False) -> Set[Tuple[str, str, str]]:
zephyr_subscriptions.add((cls.strip(), instance.strip(), recipient.strip())) zephyr_subscriptions.add((cls.strip(), instance.strip(), recipient.strip()))
return zephyr_subscriptions return zephyr_subscriptions
def open_logger() -> logging.Logger: def open_logger() -> logging.Logger:
if options.log_path is not None: if options.log_path is not None:
log_file = options.log_path log_file = options.log_path
@ -919,8 +1051,7 @@ def open_logger() -> logging.Logger:
else: else:
log_file = "/var/log/zulip/mirror-log" log_file = "/var/log/zulip/mirror-log"
else: else:
f = tempfile.NamedTemporaryFile(prefix="zulip-log.%s." % (options.user,), f = tempfile.NamedTemporaryFile(prefix="zulip-log.%s." % (options.user,), delete=False)
delete=False)
log_file = f.name log_file = f.name
# Close the file descriptor, since the logging system will # Close the file descriptor, since the logging system will
# reopen it anyway. # reopen it anyway.
@ -935,6 +1066,7 @@ def open_logger() -> logging.Logger:
logger.addHandler(file_handler) logger.addHandler(file_handler)
return logger return logger
def configure_logger(logger: logging.Logger, direction_name: Optional[str]) -> None: def configure_logger(logger: logging.Logger, direction_name: Optional[str]) -> None:
if direction_name is None: if direction_name is None:
log_format = "%(message)s" log_format = "%(message)s"
@ -949,89 +1081,70 @@ def configure_logger(logger: logging.Logger, direction_name: Optional[str]) -> N
for handler in root_logger.handlers: for handler in root_logger.handlers:
handler.setFormatter(formatter) handler.setFormatter(formatter)
def parse_args() -> Tuple[optparse.Values, List[str]]: def parse_args() -> Tuple[optparse.Values, List[str]]:
parser = optparse.OptionParser() parser = optparse.OptionParser()
parser.add_option('--forward-class-messages', parser.add_option(
default=False, '--forward-class-messages', default=False, help=optparse.SUPPRESS_HELP, action='store_true'
help=optparse.SUPPRESS_HELP, )
action='store_true') parser.add_option('--shard', help=optparse.SUPPRESS_HELP)
parser.add_option('--shard', parser.add_option('--noshard', default=False, help=optparse.SUPPRESS_HELP, action='store_true')
help=optparse.SUPPRESS_HELP) parser.add_option('--resend-log', dest='logs_to_resend', help=optparse.SUPPRESS_HELP)
parser.add_option('--noshard', parser.add_option('--enable-resend-log', dest='resend_log_path', help=optparse.SUPPRESS_HELP)
default=False, parser.add_option('--log-path', dest='log_path', help=optparse.SUPPRESS_HELP)
help=optparse.SUPPRESS_HELP, parser.add_option(
action='store_true') '--stream-file-path',
parser.add_option('--resend-log', dest='stream_file_path',
dest='logs_to_resend', default="/home/zulip/public_streams",
help=optparse.SUPPRESS_HELP) help=optparse.SUPPRESS_HELP,
parser.add_option('--enable-resend-log', )
dest='resend_log_path', parser.add_option(
help=optparse.SUPPRESS_HELP) '--no-forward-personals',
parser.add_option('--log-path', dest='forward_personals',
dest='log_path', help=optparse.SUPPRESS_HELP,
help=optparse.SUPPRESS_HELP) default=True,
parser.add_option('--stream-file-path', action='store_false',
dest='stream_file_path', )
default="/home/zulip/public_streams", parser.add_option(
help=optparse.SUPPRESS_HELP) '--forward-mail-zephyrs',
parser.add_option('--no-forward-personals', dest='forward_mail_zephyrs',
dest='forward_personals', help=optparse.SUPPRESS_HELP,
help=optparse.SUPPRESS_HELP, default=False,
default=True, action='store_true',
action='store_false') )
parser.add_option('--forward-mail-zephyrs', parser.add_option(
dest='forward_mail_zephyrs', '--no-forward-from-zulip',
help=optparse.SUPPRESS_HELP, default=True,
default=False, dest='forward_from_zulip',
action='store_true') help=optparse.SUPPRESS_HELP,
parser.add_option('--no-forward-from-zulip', action='store_false',
default=True, )
dest='forward_from_zulip', parser.add_option('--verbose', default=False, help=optparse.SUPPRESS_HELP, action='store_true')
help=optparse.SUPPRESS_HELP, parser.add_option('--sync-subscriptions', default=False, action='store_true')
action='store_false') parser.add_option('--ignore-expired-tickets', default=False, action='store_true')
parser.add_option('--verbose', parser.add_option('--site', default=DEFAULT_SITE, help=optparse.SUPPRESS_HELP)
default=False, parser.add_option('--on-startup-command', default=None, help=optparse.SUPPRESS_HELP)
help=optparse.SUPPRESS_HELP, parser.add_option('--user', default=os.environ["USER"], help=optparse.SUPPRESS_HELP)
action='store_true') parser.add_option(
parser.add_option('--sync-subscriptions', '--stamp-path',
default=False, default="/afs/athena.mit.edu/user/t/a/tabbott/for_friends",
action='store_true') help=optparse.SUPPRESS_HELP,
parser.add_option('--ignore-expired-tickets', )
default=False, parser.add_option('--session-path', default=None, help=optparse.SUPPRESS_HELP)
action='store_true') parser.add_option('--nagios-class', default=None, help=optparse.SUPPRESS_HELP)
parser.add_option('--site', parser.add_option('--nagios-path', default=None, help=optparse.SUPPRESS_HELP)
default=DEFAULT_SITE, parser.add_option(
help=optparse.SUPPRESS_HELP) '--use-sessions', default=False, action='store_true', help=optparse.SUPPRESS_HELP
parser.add_option('--on-startup-command', )
default=None, parser.add_option(
help=optparse.SUPPRESS_HELP) '--test-mode', default=False, help=optparse.SUPPRESS_HELP, action='store_true'
parser.add_option('--user', )
default=os.environ["USER"], parser.add_option(
help=optparse.SUPPRESS_HELP) '--api-key-file', default=os.path.join(os.environ["HOME"], "Private", ".humbug-api-key")
parser.add_option('--stamp-path', )
default="/afs/athena.mit.edu/user/t/a/tabbott/for_friends",
help=optparse.SUPPRESS_HELP)
parser.add_option('--session-path',
default=None,
help=optparse.SUPPRESS_HELP)
parser.add_option('--nagios-class',
default=None,
help=optparse.SUPPRESS_HELP)
parser.add_option('--nagios-path',
default=None,
help=optparse.SUPPRESS_HELP)
parser.add_option('--use-sessions',
default=False,
action='store_true',
help=optparse.SUPPRESS_HELP)
parser.add_option('--test-mode',
default=False,
help=optparse.SUPPRESS_HELP,
action='store_true')
parser.add_option('--api-key-file',
default=os.path.join(os.environ["HOME"], "Private", ".humbug-api-key"))
return parser.parse_args() return parser.parse_args()
def die_gracefully(signal: int, frame: FrameType) -> None: def die_gracefully(signal: int, frame: FrameType) -> None:
if CURRENT_STATE == States.ZulipToZephyr or CURRENT_STATE == States.ChildSending: if CURRENT_STATE == States.ZulipToZephyr or CURRENT_STATE == States.ChildSending:
# this is a child process, so we want os._exit (no clean-up necessary) # this is a child process, so we want os._exit (no clean-up necessary)
@ -1047,6 +1160,7 @@ def die_gracefully(signal: int, frame: FrameType) -> None:
sys.exit(1) sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
# Set the SIGCHLD handler back to SIG_DFL to prevent these errors # Set the SIGCHLD handler back to SIG_DFL to prevent these errors
# when importing the "requests" module after being restarted using # when importing the "requests" module after being restarted using
@ -1070,10 +1184,18 @@ if __name__ == "__main__":
api_key = os.environ.get("HUMBUG_API_KEY") api_key = os.environ.get("HUMBUG_API_KEY")
else: else:
if not os.path.exists(options.api_key_file): if not os.path.exists(options.api_key_file):
logger.error("\n" + "\n".join(textwrap.wrap("""\ logger.error(
"\n"
+ "\n".join(
textwrap.wrap(
"""\
Could not find API key file. Could not find API key file.
You need to either place your api key file at %s, You need to either place your api key file at %s,
or specify the --api-key-file option.""" % (options.api_key_file,)))) or specify the --api-key-file option."""
% (options.api_key_file,)
)
)
)
sys.exit(1) sys.exit(1)
api_key = open(options.api_key_file).read().strip() api_key = open(options.api_key_file).read().strip()
# Store the API key in the environment so that our children # Store the API key in the environment so that our children
@ -1086,12 +1208,14 @@ or specify the --api-key-file option.""" % (options.api_key_file,))))
zulip_account_email = options.user + "@mit.edu" zulip_account_email = options.user + "@mit.edu"
import zulip import zulip
zulip_client = zulip.Client( zulip_client = zulip.Client(
email=zulip_account_email, email=zulip_account_email,
api_key=api_key, api_key=api_key,
verbose=True, verbose=True,
client="zephyr_mirror", client="zephyr_mirror",
site=options.site) site=options.site,
)
start_time = time.time() start_time = time.time()
@ -1110,9 +1234,11 @@ or specify the --api-key-file option.""" % (options.api_key_file,))))
elif options.user is not None: elif options.user is not None:
# Personals mirror on behalf of another user. # Personals mirror on behalf of another user.
pgrep_query = "%s.*--user=%s" % (pgrep_query, options.user) pgrep_query = "%s.*--user=%s" % (pgrep_query, options.user)
proc = subprocess.Popen(['pgrep', '-U', os.environ["USER"], "-f", pgrep_query], proc = subprocess.Popen(
stdout=subprocess.PIPE, ['pgrep', '-U', os.environ["USER"], "-f", pgrep_query],
stderr=subprocess.PIPE) stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, _err_unused = proc.communicate() out, _err_unused = proc.communicate()
for pid in map(int, out.split()): for pid in map(int, out.split()):
if pid == os.getpid() or pid == os.getppid(): if pid == os.getpid() or pid == os.getppid():
@ -1149,6 +1275,7 @@ or specify the --api-key-file option.""" % (options.api_key_file,))))
CURRENT_STATE = States.ZephyrToZulip CURRENT_STATE = States.ZephyrToZulip
import zephyr import zephyr
logger_name = "zephyr=>zulip" logger_name = "zephyr=>zulip"
if options.shard is not None: if options.shard is not None:
logger_name += "(%s)" % (options.shard,) logger_name += "(%s)" % (options.shard,)

View file

@ -8,20 +8,24 @@ from typing import Any, Dict, Generator, List, Tuple
with open("README.md") as fh: with open("README.md") as fh:
long_description = fh.read() long_description = fh.read()
def version() -> str: def version() -> str:
version_py = os.path.join(os.path.dirname(__file__), "zulip", "__init__.py") version_py = os.path.join(os.path.dirname(__file__), "zulip", "__init__.py")
with open(version_py) as in_handle: with open(version_py) as in_handle:
version_line = next(itertools.dropwhile(lambda x: not x.startswith("__version__"), version_line = next(
in_handle)) itertools.dropwhile(lambda x: not x.startswith("__version__"), in_handle)
)
version = version_line.split('=')[-1].strip().replace('"', '') version = version_line.split('=')[-1].strip().replace('"', '')
return version return version
def recur_expand(target_root: Any, dir: Any) -> Generator[Tuple[str, List[str]], None, None]: def recur_expand(target_root: Any, dir: Any) -> Generator[Tuple[str, List[str]], None, None]:
for root, _, files in os.walk(dir): for root, _, files in os.walk(dir):
paths = [os.path.join(root, f) for f in files] paths = [os.path.join(root, f) for f in files]
if len(paths): if len(paths):
yield os.path.join(target_root, root), paths yield os.path.join(target_root, root), paths
# We should be installable with either setuptools or distutils. # We should be installable with either setuptools or distutils.
package_info = dict( package_info = dict(
name='zulip', name='zulip',
@ -56,22 +60,24 @@ package_info = dict(
'zulip-send=zulip.send:main', 'zulip-send=zulip.send:main',
'zulip-api-examples=zulip.api_examples:main', 'zulip-api-examples=zulip.api_examples:main',
'zulip-matrix-bridge=integrations.bridge_with_matrix.matrix_bridge:main', 'zulip-matrix-bridge=integrations.bridge_with_matrix.matrix_bridge:main',
'zulip-api=zulip.cli:cli' 'zulip-api=zulip.cli:cli',
], ],
}, },
package_data={'zulip': ["py.typed"]}, package_data={'zulip': ["py.typed"]},
) # type: Dict[str, Any] ) # type: Dict[str, Any]
setuptools_info = dict( setuptools_info = dict(
install_requires=['requests[security]>=0.12.1', install_requires=[
'matrix_client', 'requests[security]>=0.12.1',
'distro', 'matrix_client',
'click', 'distro',
], 'click',
],
) )
try: try:
from setuptools import find_packages, setup from setuptools import find_packages, setup
package_info.update(setuptools_info) package_info.update(setuptools_info)
package_info['packages'] = find_packages(exclude=['tests']) package_info['packages'] = find_packages(exclude=['tests'])
@ -82,7 +88,8 @@ except ImportError:
# Manual dependency check # Manual dependency check
try: try:
import requests import requests
assert(LooseVersion(requests.__version__) >= LooseVersion('0.12.1'))
assert LooseVersion(requests.__version__) >= LooseVersion('0.12.1')
except (ImportError, AssertionError): except (ImportError, AssertionError):
print("requests >=0.12.1 is not installed", file=sys.stderr) print("requests >=0.12.1 is not installed", file=sys.stderr)
sys.exit(1) sys.exit(1)

View file

@ -12,7 +12,6 @@ from zulip import ZulipError
class TestDefaultArguments(TestCase): class TestDefaultArguments(TestCase):
def test_invalid_arguments(self) -> None: def test_invalid_arguments(self) -> None:
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage="lorem ipsum")) parser = zulip.add_default_arguments(argparse.ArgumentParser(usage="lorem ipsum"))
with self.assertRaises(SystemExit) as cm: with self.assertRaises(SystemExit) as cm:
@ -20,14 +19,18 @@ class TestDefaultArguments(TestCase):
parser.parse_args(['invalid argument']) parser.parse_args(['invalid argument'])
self.assertEqual(cm.exception.code, 2) self.assertEqual(cm.exception.code, 2)
# Assert that invalid arguments exit with printing the full usage (non-standard behavior) # Assert that invalid arguments exit with printing the full usage (non-standard behavior)
self.assertTrue(mock_stderr.getvalue().startswith("""usage: lorem ipsum self.assertTrue(
mock_stderr.getvalue().startswith(
"""usage: lorem ipsum
optional arguments: optional arguments:
-h, --help show this help message and exit -h, --help show this help message and exit
Zulip API configuration: Zulip API configuration:
--site ZULIP_SITE Zulip server URI --site ZULIP_SITE Zulip server URI
""")) """
)
)
@patch('os.path.exists', return_value=False) @patch('os.path.exists', return_value=False)
def test_config_path_with_tilde(self, mock_os_path_exists: bool) -> None: def test_config_path_with_tilde(self, mock_os_path_exists: bool) -> None:
@ -37,8 +40,12 @@ Zulip API configuration:
with self.assertRaises(ZulipError) as cm: with self.assertRaises(ZulipError) as cm:
zulip.init_from_options(args) zulip.init_from_options(args)
expanded_test_path = os.path.abspath(os.path.expanduser(test_path)) expanded_test_path = os.path.abspath(os.path.expanduser(test_path))
self.assertEqual(str(cm.exception), 'api_key or email not specified and ' self.assertEqual(
'file {} does not exist'.format(expanded_test_path)) str(cm.exception),
'api_key or email not specified and '
'file {} does not exist'.format(expanded_test_path),
)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View file

@ -20,5 +20,6 @@ class TestHashUtilDecode(TestCase):
with self.subTest(encoded_string=encoded_string): with self.subTest(encoded_string=encoded_string):
self.assertEqual(zulip.hash_util_decode(encoded_string), decoded_string) self.assertEqual(zulip.hash_util_decode(encoded_string), decoded_string)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

File diff suppressed because it is too large Load diff

View file

@ -10,19 +10,21 @@ def main() -> None:
Prints the path to the Zulip API example scripts.""" Prints the path to the Zulip API example scripts."""
parser = argparse.ArgumentParser(usage=usage) parser = argparse.ArgumentParser(usage=usage)
parser.add_argument('script_name', parser.add_argument(
nargs='?', 'script_name', nargs='?', default='', help='print path to the script <script_name>'
default='', )
help='print path to the script <script_name>')
args = parser.parse_args() args = parser.parse_args()
zulip_path = os.path.abspath(os.path.dirname(zulip.__file__)) zulip_path = os.path.abspath(os.path.dirname(zulip.__file__))
examples_path = os.path.abspath(os.path.join(zulip_path, 'examples', args.script_name)) examples_path = os.path.abspath(os.path.join(zulip_path, 'examples', args.script_name))
if os.path.isdir(examples_path) or (args.script_name and os.path.isfile(examples_path)): if os.path.isdir(examples_path) or (args.script_name and os.path.isfile(examples_path)):
print(examples_path) print(examples_path)
else: else:
raise OSError("Examples cannot be accessed at {}: {} does not exist!" raise OSError(
.format(examples_path, "Examples cannot be accessed at {}: {} does not exist!".format(
"File" if args.script_name else "Directory")) examples_path, "File" if args.script_name else "Directory"
)
)
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View file

@ -23,9 +23,13 @@ options = parser.parse_args()
client = zulip.init_from_options(options) client = zulip.init_from_options(options)
print(client.create_user({ print(
'email': options.new_email, client.create_user(
'password': options.new_password, {
'full_name': options.new_full_name, 'email': options.new_email,
'short_name': options.new_short_name 'password': options.new_password,
})) 'full_name': options.new_full_name,
'short_name': options.new_short_name,
}
)
)

View file

@ -29,11 +29,15 @@ options = parser.parse_args()
client = zulip.init_from_options(options) client = zulip.init_from_options(options)
print(client.update_stream({ print(
'stream_id': options.stream_id, client.update_stream(
'description': quote(options.description), {
'new_name': quote(options.new_name), 'stream_id': options.stream_id,
'is_private': options.private, 'description': quote(options.description),
'is_announcement_only': options.announcement_only, 'new_name': quote(options.new_name),
'history_public_to_subscribers': options.history_public_to_subscribers 'is_private': options.private,
})) 'is_announcement_only': options.announcement_only,
'history_public_to_subscribers': options.history_public_to_subscribers,
}
)
)

View file

@ -15,8 +15,12 @@ Example: get-history --stream announce --topic important"""
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage))
parser.add_argument('--stream', required=True, help="The stream name to get the history") parser.add_argument('--stream', required=True, help="The stream name to get the history")
parser.add_argument('--topic', help="The topic name to get the history") parser.add_argument('--topic', help="The topic name to get the history")
parser.add_argument('--filename', default='history.json', help="The file name to store the fetched \ parser.add_argument(
history.\n Default 'history.json'") '--filename',
default='history.json',
help="The file name to store the fetched \
history.\n Default 'history.json'",
)
options = parser.parse_args() options = parser.parse_args()
client = zulip.init_from_options(options) client = zulip.init_from_options(options)
@ -33,7 +37,7 @@ request = {
'num_after': 1000, 'num_after': 1000,
'narrow': narrow, 'narrow': narrow,
'client_gravatar': False, 'client_gravatar': False,
'apply_markdown': False 'apply_markdown': False,
} }
all_messages = [] # type: List[Dict[str, Any]] all_messages = [] # type: List[Dict[str, Any]]

View file

@ -28,12 +28,16 @@ options = parser.parse_args()
client = zulip.init_from_options(options) client = zulip.init_from_options(options)
print(client.get_messages({ print(
'anchor': options.anchor, client.get_messages(
'use_first_unread_anchor': options.use_first_unread_anchor, {
'num_before': options.num_before, 'anchor': options.anchor,
'num_after': options.num_after, 'use_first_unread_anchor': options.use_first_unread_anchor,
'narrow': options.narrow, 'num_before': options.num_before,
'client_gravatar': options.client_gravatar, 'num_after': options.num_after,
'apply_markdown': options.apply_markdown 'narrow': options.narrow,
})) 'client_gravatar': options.client_gravatar,
'apply_markdown': options.apply_markdown,
}
)
)

View file

@ -18,13 +18,10 @@ options = parser.parse_args()
client = zulip.init_from_options(options) client = zulip.init_from_options(options)
OPERATIONS = { OPERATIONS = {'mute': 'add', 'unmute': 'remove'}
'mute': 'add',
'unmute': 'remove'
}
print(client.mute_topic({ print(
'op': OPERATIONS[options.op], client.mute_topic(
'stream': options.stream, {'op': OPERATIONS[options.op], 'stream': options.stream, 'topic': options.topic}
'topic': options.topic )
})) )

View file

@ -19,9 +19,11 @@ options = parser.parse_args()
client = zulip.init_from_options(options) client = zulip.init_from_options(options)
def print_event(event: Dict[str, Any]) -> None: def print_event(event: Dict[str, Any]) -> None:
print(event) print(event)
# This is a blocking call, and will continuously poll for new events # This is a blocking call, and will continuously poll for new events
# Note also the filter here is messages to the stream Denmark; if you # Note also the filter here is messages to the stream Denmark; if you
# don't specify event_types it'll print all events. # don't specify event_types it'll print all events.

View file

@ -19,8 +19,10 @@ options = parser.parse_args()
client = zulip.init_from_options(options) client = zulip.init_from_options(options)
def print_message(message: Dict[str, Any]) -> None: def print_message(message: Dict[str, Any]) -> None:
print(message) print(message)
# This is a blocking call, and will continuously poll for new messages # This is a blocking call, and will continuously poll for new messages
client.call_on_each_message(print_message) client.call_on_each_message(print_message)

View file

@ -20,5 +20,4 @@ options = parser.parse_args()
client = zulip.init_from_options(options) client = zulip.init_from_options(options)
print(client.add_subscriptions([{"name": stream_name} for stream_name in print(client.add_subscriptions([{"name": stream_name} for stream_name in options.streams.split()]))
options.streams.split()]))

View file

@ -19,8 +19,8 @@ options = parser.parse_args()
client = zulip.init_from_options(options) client = zulip.init_from_options(options)
print(client.update_message_flags({ print(
'op': options.op, client.update_message_flags(
'flag': options.flag, {'op': options.op, 'flag': options.flag, 'messages': options.messages}
'messages': options.messages )
})) )

View file

@ -10,6 +10,7 @@ import zulip
class StringIO(_StringIO): class StringIO(_StringIO):
name = '' # https://github.com/python/typeshed/issues/598 name = '' # https://github.com/python/typeshed/issues/598
usage = """upload-file [options] usage = """upload-file [options]
Upload a file, and print the corresponding URI. Upload a file, and print the corresponding URI.

View file

@ -52,13 +52,16 @@ streams_to_watch = ['new members']
# These streams will cause anyone who sends a message there to be removed from the watchlist # These streams will cause anyone who sends a message there to be removed from the watchlist
streams_to_cancel = ['development help'] streams_to_cancel = ['development help']
def get_watchlist() -> List[Any]: def get_watchlist() -> List[Any]:
storage = client.get_storage() storage = client.get_storage()
return list(storage['storage'].values()) return list(storage['storage'].values())
def set_watchlist(watchlist: List[str]) -> None: def set_watchlist(watchlist: List[str]) -> None:
client.update_storage({'storage': dict(enumerate(watchlist))}) client.update_storage({'storage': dict(enumerate(watchlist))})
def handle_event(event: Dict[str, Any]) -> None: def handle_event(event: Dict[str, Any]) -> None:
try: try:
if event['type'] == 'realm_user' and event['op'] == 'add': if event['type'] == 'realm_user' and event['op'] == 'add':
@ -74,11 +77,13 @@ def handle_event(event: Dict[str, Any]) -> None:
if event['message']['sender_email'] in watchlist: if event['message']['sender_email'] in watchlist:
watchlist.remove(event['message']['sender_email']) watchlist.remove(event['message']['sender_email'])
if stream not in streams_to_cancel: if stream not in streams_to_cancel:
client.send_message({ client.send_message(
'type': 'private', {
'to': event['message']['sender_email'], 'type': 'private',
'content': welcome_text.format(event['message']['sender_short_name']) 'to': event['message']['sender_email'],
}) 'content': welcome_text.format(event['message']['sender_short_name']),
}
)
set_watchlist(watchlist) set_watchlist(watchlist)
return return
except Exception as err: except Exception as err:
@ -89,5 +94,6 @@ def start_event_handler() -> None:
print("Starting event handler...") print("Starting event handler...")
client.call_on_each_event(handle_event, event_types=['realm_user', 'message']) client.call_on_each_event(handle_event, event_types=['realm_user', 'message'])
client = zulip.Client() client = zulip.Client()
start_event_handler() start_event_handler()

View file

@ -12,12 +12,15 @@ logging.basicConfig()
log = logging.getLogger('zulip-send') log = logging.getLogger('zulip-send')
def do_send_message(client: zulip.Client, message_data: Dict[str, Any]) -> bool: def do_send_message(client: zulip.Client, message_data: Dict[str, Any]) -> bool:
'''Sends a message and optionally prints status about the same.''' '''Sends a message and optionally prints status about the same.'''
if message_data['type'] == 'stream': if message_data['type'] == 'stream':
log.info('Sending message to stream "%s", subject "%s"... ' % log.info(
(message_data['to'], message_data['subject'])) 'Sending message to stream "%s", subject "%s"... '
% (message_data['to'], message_data['subject'])
)
else: else:
log.info('Sending message to %s... ' % (message_data['to'],)) log.info('Sending message to %s... ' % (message_data['to'],))
response = client.send_message(message_data) response = client.send_message(message_data)
@ -28,6 +31,7 @@ def do_send_message(client: zulip.Client, message_data: Dict[str, Any]) -> bool:
log.error(response['msg']) log.error(response['msg'])
return False return False
def main() -> int: def main() -> int:
usage = """zulip-send [options] [recipient...] usage = """zulip-send [options] [recipient...]
@ -41,22 +45,29 @@ def main() -> int:
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage))
parser.add_argument('recipients', parser.add_argument(
nargs='*', 'recipients', nargs='*', help='email addresses of the recipients of the message'
help='email addresses of the recipients of the message') )
parser.add_argument('-m', '--message', parser.add_argument(
help='Specifies the message to send, prevents interactive prompting.') '-m', '--message', help='Specifies the message to send, prevents interactive prompting.'
)
group = parser.add_argument_group('Stream parameters') group = parser.add_argument_group('Stream parameters')
group.add_argument('-s', '--stream', group.add_argument(
dest='stream', '-s',
action='store', '--stream',
help='Allows the user to specify a stream for the message.') dest='stream',
group.add_argument('-S', '--subject', action='store',
dest='subject', help='Allows the user to specify a stream for the message.',
action='store', )
help='Allows the user to specify a subject for the message.') group.add_argument(
'-S',
'--subject',
dest='subject',
action='store',
help='Allows the user to specify a subject for the message.',
)
options = parser.parse_args() options = parser.parse_args()
@ -93,5 +104,6 @@ def main() -> int:
return 1 return 1
return 0 return 0
if __name__ == '__main__': if __name__ == '__main__':
sys.exit(main()) sys.exit(main())

View file

@ -52,7 +52,7 @@ package_info = dict(
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'zulip-run-bot=zulip_bots.run:main', 'zulip-run-bot=zulip_bots.run:main',
'zulip-terminal=zulip_bots.terminal:main' 'zulip-terminal=zulip_bots.terminal:main',
], ],
}, },
include_package_data=True, include_package_data=True,
@ -71,6 +71,7 @@ setuptools_info = dict(
try: try:
from setuptools import find_packages, setup from setuptools import find_packages, setup
package_info.update(setuptools_info) package_info.update(setuptools_info)
package_info['packages'] = find_packages() package_info['packages'] = find_packages()
package_info['package_data'] = package_data package_info['package_data'] = package_data
@ -85,11 +86,13 @@ except ImportError:
try: try:
module = import_module(module_name) # type: Any module = import_module(module_name) # type: Any
if version is not None: if version is not None:
assert(LooseVersion(module.__version__) >= LooseVersion(version)) assert LooseVersion(module.__version__) >= LooseVersion(version)
except (ImportError, AssertionError): except (ImportError, AssertionError):
if version is not None: if version is not None:
print("{name}>={version} is not installed.".format( print(
name=module_name, version=version), file=sys.stderr) "{name}>={version} is not installed.".format(name=module_name, version=version),
file=sys.stderr,
)
else: else:
print("{name} is not installed.".format(name=module_name), file=sys.stderr) print("{name} is not installed.".format(name=module_name), file=sys.stderr)
sys.exit(1) sys.exit(1)

View file

@ -12,22 +12,29 @@ class BaremetricsHandler:
self.config_info = bot_handler.get_config_info('baremetrics') self.config_info = bot_handler.get_config_info('baremetrics')
self.api_key = self.config_info['api_key'] self.api_key = self.config_info['api_key']
self.auth_header = { self.auth_header = {'Authorization': 'Bearer ' + self.api_key}
'Authorization': 'Bearer ' + self.api_key
}
self.commands = ['help', self.commands = [
'list-commands', 'help',
'account-info', 'list-commands',
'list-sources', 'account-info',
'list-plans <source_id>', 'list-sources',
'list-customers <source_id>', 'list-plans <source_id>',
'list-subscriptions <source_id>', 'list-customers <source_id>',
'create-plan <source_id> <oid> <name> <currency> <amount> <interval> <interval_count>'] 'list-subscriptions <source_id>',
'create-plan <source_id> <oid> <name> <currency> <amount> <interval> <interval_count>',
]
self.descriptions = ['Display bot info', 'Display the list of available commands', 'Display the account info', self.descriptions = [
'List the sources', 'List the plans for the source', 'List the customers in the source', 'Display bot info',
'List the subscriptions in the source', 'Create a plan in the given source'] 'Display the list of available commands',
'Display the account info',
'List the sources',
'List the plans for the source',
'List the customers in the source',
'List the subscriptions in the source',
'Create a plan in the given source',
]
self.check_api_key(bot_handler) self.check_api_key(bot_handler)
@ -114,13 +121,16 @@ class BaremetricsHandler:
account_data = account_response.json() account_data = account_response.json()
account_data = account_data['account'] account_data = account_data['account']
template = ['**Your account information:**', template = [
'Id: {id}', '**Your account information:**',
'Company: {company}', 'Id: {id}',
'Default Currency: {currency}'] 'Company: {company}',
'Default Currency: {currency}',
]
return "\n".join(template).format(currency=account_data['default_currency']['name'], return "\n".join(template).format(
**account_data) currency=account_data['default_currency']['name'], **account_data
)
def get_sources(self) -> str: def get_sources(self) -> str:
url = 'https://api.baremetrics.com/v1/sources' url = 'https://api.baremetrics.com/v1/sources'
@ -131,9 +141,9 @@ class BaremetricsHandler:
response = '**Listing sources:** \n' response = '**Listing sources:** \n'
for index, source in enumerate(sources_data): for index, source in enumerate(sources_data):
response += ('{_count}.ID: {id}\n' response += (
'Provider: {provider}\n' '{_count}.ID: {id}\n' 'Provider: {provider}\n' 'Provider ID: {provider_id}\n\n'
'Provider ID: {provider_id}\n\n').format(_count=index + 1, **source) ).format(_count=index + 1, **source)
return response return response
@ -144,19 +154,20 @@ class BaremetricsHandler:
plans_data = plans_response.json() plans_data = plans_response.json()
plans_data = plans_data['plans'] plans_data = plans_data['plans']
template = '\n'.join(['{_count}.Name: {name}', template = '\n'.join(
'Active: {active}', [
'Interval: {interval}', '{_count}.Name: {name}',
'Interval Count: {interval_count}', 'Active: {active}',
'Amounts:']) 'Interval: {interval}',
'Interval Count: {interval_count}',
'Amounts:',
]
)
response = ['**Listing plans:**'] response = ['**Listing plans:**']
for index, plan in enumerate(plans_data): for index, plan in enumerate(plans_data):
response += ( response += (
[template.format(_count=index + 1, **plan)] [template.format(_count=index + 1, **plan)]
+ [ + [' - {amount} {currency}'.format(**amount) for amount in plan['amounts']]
' - {amount} {currency}'.format(**amount)
for amount in plan['amounts']
]
+ [''] + ['']
) )
@ -170,13 +181,17 @@ class BaremetricsHandler:
customers_data = customers_data['customers'] customers_data = customers_data['customers']
# FIXME BUG here? mismatch of name and display name? # FIXME BUG here? mismatch of name and display name?
template = '\n'.join(['{_count}.Name: {display_name}', template = '\n'.join(
'Display Name: {name}', [
'OID: {oid}', '{_count}.Name: {display_name}',
'Active: {is_active}', 'Display Name: {name}',
'Email: {email}', 'OID: {oid}',
'Notes: {notes}', 'Active: {is_active}',
'Current Plans:']) 'Email: {email}',
'Notes: {notes}',
'Current Plans:',
]
)
response = ['**Listing customers:**'] response = ['**Listing customers:**']
for index, customer in enumerate(customers_data): for index, customer in enumerate(customers_data):
response += ( response += (
@ -194,13 +209,17 @@ class BaremetricsHandler:
subscriptions_data = subscriptions_response.json() subscriptions_data = subscriptions_response.json()
subscriptions_data = subscriptions_data['subscriptions'] subscriptions_data = subscriptions_data['subscriptions']
template = '\n'.join(['{_count}.Customer Name: {name}', template = '\n'.join(
'Customer Display Name: {display_name}', [
'Customer OID: {oid}', '{_count}.Customer Name: {name}',
'Customer Email: {email}', 'Customer Display Name: {display_name}',
'Active: {_active}', 'Customer OID: {oid}',
'Plan Name: {_plan_name}', 'Customer Email: {email}',
'Plan Amounts:']) 'Active: {_active}',
'Plan Name: {_plan_name}',
'Plan Amounts:',
]
)
response = ['**Listing subscriptions:**'] response = ['**Listing subscriptions:**']
for index, subscription in enumerate(subscriptions_data): for index, subscription in enumerate(subscriptions_data):
response += ( response += (
@ -209,7 +228,7 @@ class BaremetricsHandler:
_count=index + 1, _count=index + 1,
_active=subscription['active'], _active=subscription['active'],
_plan_name=subscription['plan']['name'], _plan_name=subscription['plan']['name'],
**subscription['customer'] **subscription['customer'],
) )
] ]
+ [ + [
@ -228,7 +247,7 @@ class BaremetricsHandler:
'currency': parameters[3], 'currency': parameters[3],
'amount': int(parameters[4]), 'amount': int(parameters[4]),
'interval': parameters[5], 'interval': parameters[5],
'interval_count': int(parameters[6]) 'interval_count': int(parameters[6]),
} # type: Any } # type: Any
url = 'https://api.baremetrics.com/v1/{}/plans'.format(parameters[0]) url = 'https://api.baremetrics.com/v1/{}/plans'.format(parameters[0])
@ -238,4 +257,5 @@ class BaremetricsHandler:
else: else:
return 'Invalid Arguments Error.' return 'Invalid Arguments Error.'
handler_class = BaremetricsHandler handler_class = BaremetricsHandler

View file

@ -8,68 +8,83 @@ class TestBaremetricsBot(BotTestCase, DefaultTests):
bot_name = "baremetrics" bot_name = "baremetrics"
def test_bot_responds_to_empty_message(self) -> None: def test_bot_responds_to_empty_message(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \ with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
patch('requests.get'):
self.verify_reply('', 'No Command Specified') self.verify_reply('', 'No Command Specified')
def test_help_query(self) -> None: def test_help_query(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \ with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
patch('requests.get'): self.verify_reply(
self.verify_reply('help', ''' 'help',
'''
This bot gives updates about customer behavior, financial performance, and analytics This bot gives updates about customer behavior, financial performance, and analytics
for an organization using the Baremetrics Api.\n for an organization using the Baremetrics Api.\n
Enter `list-commands` to show the list of available commands. Enter `list-commands` to show the list of available commands.
Version 1.0 Version 1.0
''') ''',
)
def test_list_commands_command(self) -> None: def test_list_commands_command(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \ with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
patch('requests.get'): self.verify_reply(
self.verify_reply('list-commands', '**Available Commands:** \n' 'list-commands',
' - help : Display bot info\n' '**Available Commands:** \n'
' - list-commands : Display the list of available commands\n' ' - help : Display bot info\n'
' - account-info : Display the account info\n' ' - list-commands : Display the list of available commands\n'
' - list-sources : List the sources\n' ' - account-info : Display the account info\n'
' - list-plans <source_id> : List the plans for the source\n' ' - list-sources : List the sources\n'
' - list-customers <source_id> : List the customers in the source\n' ' - list-plans <source_id> : List the plans for the source\n'
' - list-subscriptions <source_id> : List the subscriptions in the ' ' - list-customers <source_id> : List the customers in the source\n'
'source\n' ' - list-subscriptions <source_id> : List the subscriptions in the '
' - create-plan <source_id> <oid> <name> <currency> <amount> <interval> ' 'source\n'
'<interval_count> : Create a plan in the given source\n') ' - create-plan <source_id> <oid> <name> <currency> <amount> <interval> '
'<interval_count> : Create a plan in the given source\n',
)
def test_account_info_command(self) -> None: def test_account_info_command(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}): with self.mock_config_info({'api_key': 'TEST'}):
with self.mock_http_conversation('account_info'): with self.mock_http_conversation('account_info'):
self.verify_reply('account-info', '**Your account information:**\nId: 376418\nCompany: NA\nDefault ' self.verify_reply(
'Currency: United States Dollar') 'account-info',
'**Your account information:**\nId: 376418\nCompany: NA\nDefault '
'Currency: United States Dollar',
)
def test_list_sources_command(self) -> None: def test_list_sources_command(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}): with self.mock_config_info({'api_key': 'TEST'}):
with self.mock_http_conversation('list_sources'): with self.mock_http_conversation('list_sources'):
self.verify_reply('list-sources', '**Listing sources:** \n1.ID: 5f7QC5NC0Ywgcu\nProvider: ' self.verify_reply(
'baremetrics\nProvider ID: None\n\n') 'list-sources',
'**Listing sources:** \n1.ID: 5f7QC5NC0Ywgcu\nProvider: '
'baremetrics\nProvider ID: None\n\n',
)
def test_list_plans_command(self) -> None: def test_list_plans_command(self) -> None:
r = '**Listing plans:**\n1.Name: Plan 1\nActive: True\nInterval: year\nInterval Count: 1\nAmounts:\n' \ r = (
' - 450000 USD\n\n2.Name: Plan 2\nActive: True\nInterval: year\nInterval Count: 1\nAmounts:\n' \ '**Listing plans:**\n1.Name: Plan 1\nActive: True\nInterval: year\nInterval Count: 1\nAmounts:\n'
' - 450000 USD\n\n2.Name: Plan 2\nActive: True\nInterval: year\nInterval Count: 1\nAmounts:\n'
' - 450000 USD\n' ' - 450000 USD\n'
)
with self.mock_config_info({'api_key': 'TEST'}): with self.mock_config_info({'api_key': 'TEST'}):
with self.mock_http_conversation('list_plans'): with self.mock_http_conversation('list_plans'):
self.verify_reply('list-plans TEST', r) self.verify_reply('list-plans TEST', r)
def test_list_customers_command(self) -> None: def test_list_customers_command(self) -> None:
r = '**Listing customers:**\n1.Name: Customer 1\nDisplay Name: Customer 1\nOID: customer_1\nActive: True\n' \ r = (
'**Listing customers:**\n1.Name: Customer 1\nDisplay Name: Customer 1\nOID: customer_1\nActive: True\n'
'Email: customer_1@baremetrics.com\nNotes: Here are some notes\nCurrent Plans:\n - Plan 1\n' 'Email: customer_1@baremetrics.com\nNotes: Here are some notes\nCurrent Plans:\n - Plan 1\n'
)
with self.mock_config_info({'api_key': 'TEST'}): with self.mock_config_info({'api_key': 'TEST'}):
with self.mock_http_conversation('list_customers'): with self.mock_http_conversation('list_customers'):
self.verify_reply('list-customers TEST', r) self.verify_reply('list-customers TEST', r)
def test_list_subscriptions_command(self) -> None: def test_list_subscriptions_command(self) -> None:
r = '**Listing subscriptions:**\n1.Customer Name: Customer 1\nCustomer Display Name: Customer 1\n' \ r = (
'Customer OID: customer_1\nCustomer Email: customer_1@baremetrics.com\nActive: True\n' \ '**Listing subscriptions:**\n1.Customer Name: Customer 1\nCustomer Display Name: Customer 1\n'
'Customer OID: customer_1\nCustomer Email: customer_1@baremetrics.com\nActive: True\n'
'Plan Name: Plan 1\nPlan Amounts:\n - 1000 $\n' 'Plan Name: Plan 1\nPlan Amounts:\n - 1000 $\n'
)
with self.mock_config_info({'api_key': 'TEST'}): with self.mock_config_info({'api_key': 'TEST'}):
with self.mock_http_conversation('list_subscriptions'): with self.mock_http_conversation('list_subscriptions'):
@ -84,34 +99,30 @@ class TestBaremetricsBot(BotTestCase, DefaultTests):
bot_test_instance.initialize(StubBotHandler()) bot_test_instance.initialize(StubBotHandler())
def test_invalid_command(self) -> None: def test_invalid_command(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \ with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
patch('requests.get'):
self.verify_reply('abcd', 'Invalid Command.') self.verify_reply('abcd', 'Invalid Command.')
def test_missing_params(self) -> None: def test_missing_params(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \ with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
patch('requests.get'):
self.verify_reply('list-plans', 'Missing Params.') self.verify_reply('list-plans', 'Missing Params.')
def test_key_error(self) -> None: def test_key_error(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \ with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
patch('requests.get'):
with self.mock_http_conversation('test_key_error'): with self.mock_http_conversation('test_key_error'):
self.verify_reply('list-plans TEST', 'Invalid Response From API.') self.verify_reply('list-plans TEST', 'Invalid Response From API.')
def test_create_plan_command(self) -> None: def test_create_plan_command(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \ with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
patch('requests.get'):
with self.mock_http_conversation('create_plan'): with self.mock_http_conversation('create_plan'):
self.verify_reply('create-plan TEST 1 TEST USD 123 TEST 123', 'Plan Created.') self.verify_reply('create-plan TEST 1 TEST USD 123 TEST 123', 'Plan Created.')
def test_create_plan_error_command(self) -> None: def test_create_plan_error_command(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \ with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
patch('requests.get'):
with self.mock_http_conversation('create_plan_error'): with self.mock_http_conversation('create_plan_error'):
self.verify_reply('create-plan TEST 1 TEST USD 123 TEST 123', 'Invalid Arguments Error.') self.verify_reply(
'create-plan TEST 1 TEST USD 123 TEST 123', 'Invalid Arguments Error.'
)
def test_create_plan_argnum_error_command(self) -> None: def test_create_plan_argnum_error_command(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \ with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
patch('requests.get'):
self.verify_reply('create-plan alpha beta', 'Invalid number of arguments.') self.verify_reply('create-plan alpha beta', 'Invalid number of arguments.')

View file

@ -16,6 +16,7 @@ following the syntax shown below :smile:.\n \
\n* `comment`**:** Add a comment [**NOTE:** Optional field, default is *None*]\ \n* `comment`**:** Add a comment [**NOTE:** Optional field, default is *None*]\
''' '''
def get_beeminder_response(message_content: str, config_info: Dict[str, str]) -> str: def get_beeminder_response(message_content: str, config_info: Dict[str, str]) -> str:
username = config_info['username'] username = config_info['username']
goalname = config_info['goalname'] goalname = config_info['goalname']
@ -25,37 +26,36 @@ def get_beeminder_response(message_content: str, config_info: Dict[str, str]) ->
if message_content == '' or message_content == 'help': if message_content == '' or message_content == 'help':
return help_message return help_message
url = "https://www.beeminder.com/api/v1/users/{}/goals/{}/datapoints.json".format(username, goalname) url = "https://www.beeminder.com/api/v1/users/{}/goals/{}/datapoints.json".format(
username, goalname
)
message_pieces = message_content.split(',') message_pieces = message_content.split(',')
for i in range(len(message_pieces)): for i in range(len(message_pieces)):
message_pieces[i] = message_pieces[i].strip() message_pieces[i] = message_pieces[i].strip()
if (len(message_pieces) == 1): if len(message_pieces) == 1:
payload = { payload = {"value": message_pieces[0], "auth_token": auth_token}
"value": message_pieces[0], elif len(message_pieces) == 2:
"auth_token": auth_token if message_pieces[1].isdigit():
}
elif (len(message_pieces) == 2):
if (message_pieces[1].isdigit()):
payload = { payload = {
"daystamp": message_pieces[0], "daystamp": message_pieces[0],
"value": message_pieces[1], "value": message_pieces[1],
"auth_token": auth_token "auth_token": auth_token,
} }
else: else:
payload = { payload = {
"value": message_pieces[0], "value": message_pieces[0],
"comment": message_pieces[1], "comment": message_pieces[1],
"auth_token": auth_token "auth_token": auth_token,
} }
elif (len(message_pieces) == 3): elif len(message_pieces) == 3:
payload = { payload = {
"daystamp": message_pieces[0], "daystamp": message_pieces[0],
"value": message_pieces[1], "value": message_pieces[1],
"comment": message_pieces[2], "comment": message_pieces[2],
"auth_token": auth_token "auth_token": auth_token,
} }
elif (len(message_pieces) > 3): elif len(message_pieces) > 3:
return "Make sure you follow the syntax.\n You can take a look \ return "Make sure you follow the syntax.\n You can take a look \
at syntax by: @mention-botname help" at syntax by: @mention-botname help"
@ -63,13 +63,17 @@ at syntax by: @mention-botname help"
r = requests.post(url, json=payload) r = requests.post(url, json=payload)
if r.status_code != 200: if r.status_code != 200:
if r.status_code == 401: # Handles case of invalid key and missing key if r.status_code == 401: # Handles case of invalid key and missing key
return "Error. Check your key!" return "Error. Check your key!"
else: else:
return "Error occured : {}".format(r.status_code) # Occures in case of unprocessable entity return "Error occured : {}".format(
r.status_code
) # Occures in case of unprocessable entity
else: else:
datapoint_link = "https://www.beeminder.com/{}/{}".format(username, goalname) datapoint_link = "https://www.beeminder.com/{}/{}".format(username, goalname)
return "[Datapoint]({}) created.".format(datapoint_link) # Handles the case of successful datapoint creation return "[Datapoint]({}) created.".format(
datapoint_link
) # Handles the case of successful datapoint creation
except ConnectionError as e: except ConnectionError as e:
logging.exception(str(e)) logging.exception(str(e))
return "Uh-Oh, couldn't process the request \ return "Uh-Oh, couldn't process the request \
@ -87,7 +91,9 @@ class BeeminderHandler:
# Check for valid auth_token # Check for valid auth_token
auth_token = self.config_info['auth_token'] auth_token = self.config_info['auth_token']
try: try:
r = requests.get("https://www.beeminder.com/api/v1/users/me.json", params={'auth_token': auth_token}) r = requests.get(
"https://www.beeminder.com/api/v1/users/me.json", params={'auth_token': auth_token}
)
if r.status_code == 401: if r.status_code == 401:
bot_handler.quit('Invalid key!') bot_handler.quit('Invalid key!')
except ConnectionError as e: except ConnectionError as e:
@ -100,4 +106,5 @@ class BeeminderHandler:
response = get_beeminder_response(message['content'], self.config_info) response = get_beeminder_response(message['content'], self.config_info)
bot_handler.send_reply(message, response) bot_handler.send_reply(message, response)
handler_class = BeeminderHandler handler_class = BeeminderHandler

View file

@ -7,11 +7,7 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, get_b
class TestBeeminderBot(BotTestCase, DefaultTests): class TestBeeminderBot(BotTestCase, DefaultTests):
bot_name = "beeminder" bot_name = "beeminder"
normal_config = { normal_config = {"auth_token": "XXXXXX", "username": "aaron", "goalname": "goal"}
"auth_token": "XXXXXX",
"username": "aaron",
"goalname": "goal"
}
help_message = ''' help_message = '''
You can add datapoints towards your beeminder goals \ You can add datapoints towards your beeminder goals \
@ -24,88 +20,94 @@ following the syntax shown below :smile:.\n \
''' '''
def test_bot_responds_to_empty_message(self) -> None: def test_bot_responds_to_empty_message(self) -> None:
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), self.mock_http_conversation(
self.mock_http_conversation('test_valid_auth_token'): 'test_valid_auth_token'
):
self.verify_reply('', self.help_message) self.verify_reply('', self.help_message)
def test_help_message(self) -> None: def test_help_message(self) -> None:
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), self.mock_http_conversation(
self.mock_http_conversation('test_valid_auth_token'): 'test_valid_auth_token'
):
self.verify_reply('help', self.help_message) self.verify_reply('help', self.help_message)
def test_message_with_daystamp_and_value(self) -> None: def test_message_with_daystamp_and_value(self) -> None:
bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.' bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.'
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), self.mock_http_conversation(
self.mock_http_conversation('test_valid_auth_token'), \ 'test_valid_auth_token'
self.mock_http_conversation('test_message_with_daystamp_and_value'): ), self.mock_http_conversation('test_message_with_daystamp_and_value'):
self.verify_reply('20180602, 2', bot_response) self.verify_reply('20180602, 2', bot_response)
def test_message_with_value_and_comment(self) -> None: def test_message_with_value_and_comment(self) -> None:
bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.' bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.'
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), self.mock_http_conversation(
self.mock_http_conversation('test_valid_auth_token'), \ 'test_valid_auth_token'
self.mock_http_conversation('test_message_with_value_and_comment'): ), self.mock_http_conversation('test_message_with_value_and_comment'):
self.verify_reply('2, hi there!', bot_response) self.verify_reply('2, hi there!', bot_response)
def test_message_with_daystamp_and_value_and_comment(self) -> None: def test_message_with_daystamp_and_value_and_comment(self) -> None:
bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.' bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.'
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), self.mock_http_conversation(
self.mock_http_conversation('test_valid_auth_token'), \ 'test_valid_auth_token'
self.mock_http_conversation('test_message_with_daystamp_and_value_and_comment'): ), self.mock_http_conversation('test_message_with_daystamp_and_value_and_comment'):
self.verify_reply('20180602, 2, hi there!', bot_response) self.verify_reply('20180602, 2, hi there!', bot_response)
def test_syntax_error(self) -> None: def test_syntax_error(self) -> None:
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), self.mock_http_conversation(
self.mock_http_conversation('test_valid_auth_token'): 'test_valid_auth_token'
):
bot_response = "Make sure you follow the syntax.\n You can take a look \ bot_response = "Make sure you follow the syntax.\n You can take a look \
at syntax by: @mention-botname help" at syntax by: @mention-botname help"
self.verify_reply("20180303, 50, comment, redundant comment", bot_response) self.verify_reply("20180303, 50, comment, redundant comment", bot_response)
def test_connection_error_when_handle_message(self) -> None: def test_connection_error_when_handle_message(self) -> None:
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), self.mock_http_conversation(
self.mock_http_conversation('test_valid_auth_token'), \ 'test_valid_auth_token'
patch('requests.post', side_effect=ConnectionError()), \ ), patch('requests.post', side_effect=ConnectionError()), patch('logging.exception'):
patch('logging.exception'): self.verify_reply(
self.verify_reply('?$!', 'Uh-Oh, couldn\'t process the request \ '?$!',
right now.\nPlease try again later') 'Uh-Oh, couldn\'t process the request \
right now.\nPlease try again later',
)
def test_invalid_when_handle_message(self) -> None: def test_invalid_when_handle_message(self) -> None:
get_bot_message_handler(self.bot_name) get_bot_message_handler(self.bot_name)
StubBotHandler() StubBotHandler()
with self.mock_config_info({'auth_token': 'someInvalidKey', with self.mock_config_info(
'username': 'aaron', {'auth_token': 'someInvalidKey', 'username': 'aaron', 'goalname': 'goal'}
'goalname': 'goal'}), \ ), patch('requests.get', side_effect=ConnectionError()), self.mock_http_conversation(
patch('requests.get', side_effect=ConnectionError()), \ 'test_invalid_when_handle_message'
self.mock_http_conversation('test_invalid_when_handle_message'), \ ), patch(
patch('logging.exception'): 'logging.exception'
):
self.verify_reply('5', 'Error. Check your key!') self.verify_reply('5', 'Error. Check your key!')
def test_error(self) -> None: def test_error(self) -> None:
bot_request = 'notNumber' bot_request = 'notNumber'
bot_response = "Error occured : 422" bot_response = "Error occured : 422"
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), self.mock_http_conversation(
self.mock_http_conversation('test_valid_auth_token'), \ 'test_valid_auth_token'
self.mock_http_conversation('test_error'): ), self.mock_http_conversation('test_error'):
self.verify_reply(bot_request, bot_response) self.verify_reply(bot_request, bot_response)
def test_invalid_when_initialize(self) -> None: def test_invalid_when_initialize(self) -> None:
bot = get_bot_message_handler(self.bot_name) bot = get_bot_message_handler(self.bot_name)
bot_handler = StubBotHandler() bot_handler = StubBotHandler()
with self.mock_config_info({'auth_token': 'someInvalidKey', with self.mock_config_info(
'username': 'aaron', {'auth_token': 'someInvalidKey', 'username': 'aaron', 'goalname': 'goal'}
'goalname': 'goal'}), \ ), self.mock_http_conversation('test_invalid_when_initialize'), self.assertRaises(
self.mock_http_conversation('test_invalid_when_initialize'), \ bot_handler.BotQuitException
self.assertRaises(bot_handler.BotQuitException): ):
bot.initialize(bot_handler) bot.initialize(bot_handler)
def test_connection_error_during_initialize(self) -> None: def test_connection_error_during_initialize(self) -> None:
bot = get_bot_message_handler(self.bot_name) bot = get_bot_message_handler(self.bot_name)
bot_handler = StubBotHandler() bot_handler = StubBotHandler()
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), patch(
patch('requests.get', side_effect=ConnectionError()), \ 'requests.get', side_effect=ConnectionError()
patch('logging.exception') as mock_logging: ), patch('logging.exception') as mock_logging:
bot.initialize(bot_handler) bot.initialize(bot_handler)
self.assertTrue(mock_logging.called) self.assertTrue(mock_logging.called)

View file

@ -8,12 +8,11 @@ import chess.uci
from zulip_bots.lib import BotHandler from zulip_bots.lib import BotHandler
START_REGEX = re.compile('start with other user$') START_REGEX = re.compile('start with other user$')
START_COMPUTER_REGEX = re.compile( START_COMPUTER_REGEX = re.compile('start as (?P<user_color>white|black) with computer')
'start as (?P<user_color>white|black) with computer'
)
MOVE_REGEX = re.compile('do (?P<move_san>.+)$') MOVE_REGEX = re.compile('do (?P<move_san>.+)$')
RESIGN_REGEX = re.compile('resign$') RESIGN_REGEX = re.compile('resign$')
class ChessHandler: class ChessHandler:
def usage(self) -> str: def usage(self) -> str:
return ( return (
@ -29,20 +28,14 @@ class ChessHandler:
self.config_info = bot_handler.get_config_info('chess') self.config_info = bot_handler.get_config_info('chess')
try: try:
self.engine = chess.uci.popen_engine( self.engine = chess.uci.popen_engine(self.config_info['stockfish_location'])
self.config_info['stockfish_location']
)
self.engine.uci() self.engine.uci()
except FileNotFoundError: except FileNotFoundError:
# It is helpful to allow for fake Stockfish locations if the bot # It is helpful to allow for fake Stockfish locations if the bot
# runner is testing or knows they won't be using an engine. # runner is testing or knows they won't be using an engine.
print('That Stockfish doesn\'t exist. Continuing.') print('That Stockfish doesn\'t exist. Continuing.')
def handle_message( def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None:
self,
message: Dict[str, str],
bot_handler: BotHandler
) -> None:
content = message['content'] content = message['content']
if content == '': if content == '':
@ -60,7 +53,8 @@ class ChessHandler:
if bot_handler.storage.contains('is_with_computer'): if bot_handler.storage.contains('is_with_computer'):
is_with_computer = ( is_with_computer = (
# `bot_handler`'s `storage` only accepts `str` values. # `bot_handler`'s `storage` only accepts `str` values.
bot_handler.storage.get('is_with_computer') == str(True) bot_handler.storage.get('is_with_computer')
== str(True)
) )
if bot_handler.storage.contains('last_fen'): if bot_handler.storage.contains('last_fen'):
@ -70,31 +64,17 @@ class ChessHandler:
self.start(message, bot_handler) self.start(message, bot_handler)
elif start_computer_regex_match: elif start_computer_regex_match:
self.start_computer( self.start_computer(
message, message, bot_handler, start_computer_regex_match.group('user_color') == 'white'
bot_handler,
start_computer_regex_match.group('user_color') == 'white'
) )
elif move_regex_match: elif move_regex_match:
if is_with_computer: if is_with_computer:
self.move_computer( self.move_computer(
message, message, bot_handler, last_fen, move_regex_match.group('move_san')
bot_handler,
last_fen,
move_regex_match.group('move_san')
) )
else: else:
self.move( self.move(message, bot_handler, last_fen, move_regex_match.group('move_san'))
message,
bot_handler,
last_fen,
move_regex_match.group('move_san')
)
elif resign_regex_match: elif resign_regex_match:
self.resign( self.resign(message, bot_handler, last_fen)
message,
bot_handler,
last_fen
)
def start(self, message: Dict[str, str], bot_handler: BotHandler) -> None: def start(self, message: Dict[str, str], bot_handler: BotHandler) -> None:
"""Starts a game with another user, with the current user as white. """Starts a game with another user, with the current user as white.
@ -105,10 +85,7 @@ class ChessHandler:
- bot_handler: The Zulip Bots bot handler object. - bot_handler: The Zulip Bots bot handler object.
""" """
new_board = chess.Board() new_board = chess.Board()
bot_handler.send_reply( bot_handler.send_reply(message, make_start_reponse(new_board))
message,
make_start_reponse(new_board)
)
# `bot_handler`'s `storage` only accepts `str` values. # `bot_handler`'s `storage` only accepts `str` values.
bot_handler.storage.put('is_with_computer', str(False)) bot_handler.storage.put('is_with_computer', str(False))
@ -116,10 +93,7 @@ class ChessHandler:
bot_handler.storage.put('last_fen', new_board.fen()) bot_handler.storage.put('last_fen', new_board.fen())
def start_computer( def start_computer(
self, self, message: Dict[str, str], bot_handler: BotHandler, is_white_user: bool
message: Dict[str, str],
bot_handler: BotHandler,
is_white_user: bool
) -> None: ) -> None:
"""Starts a game with the computer. Replies to the bot handler. """Starts a game with the computer. Replies to the bot handler.
@ -135,10 +109,7 @@ class ChessHandler:
new_board = chess.Board() new_board = chess.Board()
if is_white_user: if is_white_user:
bot_handler.send_reply( bot_handler.send_reply(message, make_start_computer_reponse(new_board))
message,
make_start_computer_reponse(new_board)
)
# `bot_handler`'s `storage` only accepts `str` values. # `bot_handler`'s `storage` only accepts `str` values.
bot_handler.storage.put('is_with_computer', str(True)) bot_handler.storage.put('is_with_computer', str(True))
@ -152,10 +123,7 @@ class ChessHandler:
) )
def validate_board( def validate_board(
self, self, message: Dict[str, str], bot_handler: BotHandler, fen: str
message: Dict[str, str],
bot_handler: BotHandler,
fen: str
) -> Optional[chess.Board]: ) -> Optional[chess.Board]:
"""Validates a board based on its FEN string. Replies to the bot """Validates a board based on its FEN string. Replies to the bot
handler if there is an error with the board. handler if there is an error with the board.
@ -171,10 +139,7 @@ class ChessHandler:
try: try:
last_board = chess.Board(fen) last_board = chess.Board(fen)
except ValueError: except ValueError:
bot_handler.send_reply( bot_handler.send_reply(message, make_copied_wrong_response())
message,
make_copied_wrong_response()
)
return None return None
return last_board return last_board
@ -185,7 +150,7 @@ class ChessHandler:
bot_handler: BotHandler, bot_handler: BotHandler,
last_board: chess.Board, last_board: chess.Board,
move_san: str, move_san: str,
is_computer: object is_computer: object,
) -> Optional[chess.Move]: ) -> Optional[chess.Move]:
"""Validates a move based on its SAN string and the current board. """Validates a move based on its SAN string and the current board.
Replies to the bot handler if there is an error with the move. Replies to the bot handler if there is an error with the move.
@ -205,29 +170,17 @@ class ChessHandler:
try: try:
move = last_board.parse_san(move_san) move = last_board.parse_san(move_san)
except ValueError: except ValueError:
bot_handler.send_reply( bot_handler.send_reply(message, make_not_legal_response(last_board, move_san))
message,
make_not_legal_response(
last_board,
move_san
)
)
return None return None
if move not in last_board.legal_moves: if move not in last_board.legal_moves:
bot_handler.send_reply( bot_handler.send_reply(message, make_not_legal_response(last_board, move_san))
message,
make_not_legal_response(last_board, move_san)
)
return None return None
return move return move
def check_game_over( def check_game_over(
self, self, message: Dict[str, str], bot_handler: BotHandler, new_board: chess.Board
message: Dict[str, str],
bot_handler: BotHandler,
new_board: chess.Board
) -> bool: ) -> bool:
"""Checks if a game is over due to """Checks if a game is over due to
- checkmate, - checkmate,
@ -254,38 +207,24 @@ class ChessHandler:
game_over_output = '' game_over_output = ''
if new_board.is_checkmate(): if new_board.is_checkmate():
game_over_output = make_loss_response( game_over_output = make_loss_response(new_board, 'was checkmated')
new_board,
'was checkmated'
)
elif new_board.is_stalemate(): elif new_board.is_stalemate():
game_over_output = make_draw_response('stalemate') game_over_output = make_draw_response('stalemate')
elif new_board.is_insufficient_material(): elif new_board.is_insufficient_material():
game_over_output = make_draw_response( game_over_output = make_draw_response('insufficient material')
'insufficient material'
)
elif new_board.can_claim_fifty_moves(): elif new_board.can_claim_fifty_moves():
game_over_output = make_draw_response( game_over_output = make_draw_response('50 moves without a capture or pawn move')
'50 moves without a capture or pawn move'
)
elif new_board.can_claim_threefold_repetition(): elif new_board.can_claim_threefold_repetition():
game_over_output = make_draw_response('3-fold repetition') game_over_output = make_draw_response('3-fold repetition')
bot_handler.send_reply( bot_handler.send_reply(message, game_over_output)
message,
game_over_output
)
return True return True
return False return False
def move( def move(
self, self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str, move_san: str
message: Dict[str, str],
bot_handler: BotHandler,
last_fen: str,
move_san: str
) -> None: ) -> None:
"""Makes a move for a user in a game with another user. Replies to """Makes a move for a user in a game with another user. Replies to
the bot handler. the bot handler.
@ -301,13 +240,7 @@ class ChessHandler:
if not last_board: if not last_board:
return return
move = self.validate_move( move = self.validate_move(message, bot_handler, last_board, move_san, False)
message,
bot_handler,
last_board,
move_san,
False
)
if not move: if not move:
return return
@ -318,19 +251,12 @@ class ChessHandler:
if self.check_game_over(message, bot_handler, new_board): if self.check_game_over(message, bot_handler, new_board):
return return
bot_handler.send_reply( bot_handler.send_reply(message, make_move_reponse(last_board, new_board, move))
message,
make_move_reponse(last_board, new_board, move)
)
bot_handler.storage.put('last_fen', new_board.fen()) bot_handler.storage.put('last_fen', new_board.fen())
def move_computer( def move_computer(
self, self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str, move_san: str
message: Dict[str, str],
bot_handler: BotHandler,
last_fen: str,
move_san: str
) -> None: ) -> None:
"""Preforms a move for a user in a game with the computer and then """Preforms a move for a user in a game with the computer and then
makes the computer's move. Replies to the bot handler. Unlike `move`, makes the computer's move. Replies to the bot handler. Unlike `move`,
@ -350,13 +276,7 @@ class ChessHandler:
if not last_board: if not last_board:
return return
move = self.validate_move( move = self.validate_move(message, bot_handler, last_board, move_san, True)
message,
bot_handler,
last_board,
move_san,
True
)
if not move: if not move:
return return
@ -367,40 +287,22 @@ class ChessHandler:
if self.check_game_over(message, bot_handler, new_board): if self.check_game_over(message, bot_handler, new_board):
return return
computer_move = calculate_computer_move( computer_move = calculate_computer_move(new_board, self.engine)
new_board,
self.engine
)
new_board_after_computer_move = copy.copy(new_board) new_board_after_computer_move = copy.copy(new_board)
new_board_after_computer_move.push(computer_move) new_board_after_computer_move.push(computer_move)
if self.check_game_over( if self.check_game_over(message, bot_handler, new_board_after_computer_move):
message,
bot_handler,
new_board_after_computer_move
):
return return
bot_handler.send_reply( bot_handler.send_reply(
message, message, make_move_reponse(new_board, new_board_after_computer_move, computer_move)
make_move_reponse(
new_board,
new_board_after_computer_move,
computer_move
)
) )
bot_handler.storage.put( bot_handler.storage.put('last_fen', new_board_after_computer_move.fen())
'last_fen',
new_board_after_computer_move.fen()
)
def move_computer_first( def move_computer_first(
self, self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str
message: Dict[str, str],
bot_handler: BotHandler,
last_fen: str
) -> None: ) -> None:
"""Preforms a move for the computer without having the user go first in """Preforms a move for the computer without having the user go first in
a game with the computer. Replies to the bot handler. Like a game with the computer. Replies to the bot handler. Like
@ -415,44 +317,24 @@ class ChessHandler:
""" """
last_board = self.validate_board(message, bot_handler, last_fen) last_board = self.validate_board(message, bot_handler, last_fen)
computer_move = calculate_computer_move( computer_move = calculate_computer_move(last_board, self.engine)
last_board,
self.engine
)
new_board_after_computer_move = copy.copy(last_board) # type: chess.Board new_board_after_computer_move = copy.copy(last_board) # type: chess.Board
new_board_after_computer_move.push(computer_move) new_board_after_computer_move.push(computer_move)
if self.check_game_over( if self.check_game_over(message, bot_handler, new_board_after_computer_move):
message,
bot_handler,
new_board_after_computer_move
):
return return
bot_handler.send_reply( bot_handler.send_reply(
message, message, make_move_reponse(last_board, new_board_after_computer_move, computer_move)
make_move_reponse(
last_board,
new_board_after_computer_move,
computer_move
)
) )
bot_handler.storage.put( bot_handler.storage.put('last_fen', new_board_after_computer_move.fen())
'last_fen',
new_board_after_computer_move.fen()
)
# `bot_handler`'s `storage` only accepts `str` values. # `bot_handler`'s `storage` only accepts `str` values.
bot_handler.storage.put('is_with_computer', str(True)) bot_handler.storage.put('is_with_computer', str(True))
def resign( def resign(self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str) -> None:
self,
message: Dict[str, str],
bot_handler: BotHandler,
last_fen: str
) -> None:
"""Resigns the game for the current player. """Resigns the game for the current player.
Parameters: Parameters:
@ -465,13 +347,12 @@ class ChessHandler:
if not last_board: if not last_board:
return return
bot_handler.send_reply( bot_handler.send_reply(message, make_loss_response(last_board, 'resigned'))
message,
make_loss_response(last_board, 'resigned')
)
handler_class = ChessHandler handler_class = ChessHandler
def calculate_computer_move(board: chess.Board, engine: Any) -> chess.Move: def calculate_computer_move(board: chess.Board, engine: Any) -> chess.Move:
"""Calculates the computer's move. """Calculates the computer's move.
@ -485,6 +366,7 @@ def calculate_computer_move(board: chess.Board, engine: Any) -> chess.Move:
best_move_and_ponder_move = engine.go(movetime=(3000)) best_move_and_ponder_move = engine.go(movetime=(3000))
return best_move_and_ponder_move[0] return best_move_and_ponder_move[0]
def make_draw_response(reason: str) -> str: def make_draw_response(reason: str) -> str:
"""Makes a response string for a draw. """Makes a response string for a draw.
@ -496,6 +378,7 @@ def make_draw_response(reason: str) -> str:
""" """
return 'It\'s a draw because of {}!'.format(reason) return 'It\'s a draw because of {}!'.format(reason)
def make_loss_response(board: chess.Board, reason: str) -> str: def make_loss_response(board: chess.Board, reason: str) -> str:
"""Makes a response string for a loss (or win). """Makes a response string for a loss (or win).
@ -506,16 +389,14 @@ def make_loss_response(board: chess.Board, reason: str) -> str:
Returns: The loss response string. Returns: The loss response string.
""" """
return ( return ('*{}* {}. **{}** wins!\n\n' '{}').format(
'*{}* {}. **{}** wins!\n\n'
'{}'
).format(
'White' if board.turn else 'Black', 'White' if board.turn else 'Black',
reason, reason,
'Black' if board.turn else 'White', 'Black' if board.turn else 'White',
make_str(board, board.turn) make_str(board, board.turn),
) )
def make_not_legal_response(board: chess.Board, move_san: str) -> str: def make_not_legal_response(board: chess.Board, move_san: str) -> str:
"""Makes a response string for a not-legal move. """Makes a response string for a not-legal move.
@ -525,17 +406,11 @@ def make_not_legal_response(board: chess.Board, move_san: str) -> str:
Returns: The not-legal-move response string. Returns: The not-legal-move response string.
""" """
return ( return ('Sorry, the move *{}* isn\'t legal.\n\n' '{}' '\n\n\n' '{}').format(
'Sorry, the move *{}* isn\'t legal.\n\n' move_san, make_str(board, board.turn), make_footer()
'{}'
'\n\n\n'
'{}'
).format(
move_san,
make_str(board, board.turn),
make_footer()
) )
def make_copied_wrong_response() -> str: def make_copied_wrong_response() -> str:
"""Makes a response string for a FEN string that was copied wrong. """Makes a response string for a FEN string that was copied wrong.
@ -546,6 +421,7 @@ def make_copied_wrong_response() -> str:
'Please try to copy the response again from the last message!' 'Please try to copy the response again from the last message!'
) )
def make_start_reponse(board: chess.Board) -> str: def make_start_reponse(board: chess.Board) -> str:
"""Makes a response string for the first response of a game with another """Makes a response string for the first response of a game with another
user. user.
@ -563,11 +439,8 @@ def make_start_reponse(board: chess.Board) -> str:
'Now it\'s **{}**\'s turn.' 'Now it\'s **{}**\'s turn.'
'\n\n\n' '\n\n\n'
'{}' '{}'
).format( ).format(make_str(board, True), 'white' if board.turn else 'black', make_footer())
make_str(board, True),
'white' if board.turn else 'black',
make_footer()
)
def make_start_computer_reponse(board: chess.Board) -> str: def make_start_computer_reponse(board: chess.Board) -> str:
"""Makes a response string for the first response of a game with a """Makes a response string for the first response of a game with a
@ -587,17 +460,10 @@ def make_start_computer_reponse(board: chess.Board) -> str:
'Now it\'s **{}**\'s turn.' 'Now it\'s **{}**\'s turn.'
'\n\n\n' '\n\n\n'
'{}' '{}'
).format( ).format(make_str(board, True), 'white' if board.turn else 'black', make_footer())
make_str(board, True),
'white' if board.turn else 'black',
make_footer()
)
def make_move_reponse(
last_board: chess.Board, def make_move_reponse(last_board: chess.Board, new_board: chess.Board, move: chess.Move) -> str:
new_board: chess.Board,
move: chess.Move
) -> str:
"""Makes a response string for after a move is made. """Makes a response string for after a move is made.
Parameters: Parameters:
@ -623,9 +489,10 @@ def make_move_reponse(
last_board.san(move), last_board.san(move),
make_str(new_board, new_board.turn), make_str(new_board, new_board.turn),
'white' if new_board.turn else 'black', 'white' if new_board.turn else 'black',
make_footer() make_footer(),
) )
def make_footer() -> str: def make_footer() -> str:
"""Makes a footer to be appended to the bottom of other, actionable """Makes a footer to be appended to the bottom of other, actionable
responses. responses.
@ -637,6 +504,7 @@ def make_footer() -> str:
'response.*' 'response.*'
) )
def make_str(board: chess.Board, is_white_on_bottom: bool) -> str: def make_str(board: chess.Board, is_white_on_bottom: bool) -> str:
"""Converts a board object into a string to be used in Markdown. Backticks """Converts a board object into a string to be used in Markdown. Backticks
are added around the string to preserve formatting. are added around the string to preserve formatting.
@ -654,14 +522,14 @@ def make_str(board: chess.Board, is_white_on_bottom: bool) -> str:
replaced_str = replace_with_unicode(default_str) replaced_str = replace_with_unicode(default_str)
replaced_and_guided_str = guide_with_numbers(replaced_str) replaced_and_guided_str = guide_with_numbers(replaced_str)
properly_flipped_str = ( properly_flipped_str = (
replaced_and_guided_str if is_white_on_bottom replaced_and_guided_str if is_white_on_bottom else replaced_and_guided_str[::-1]
else replaced_and_guided_str[::-1]
) )
trimmed_str = trim_whitespace_before_newline(properly_flipped_str) trimmed_str = trim_whitespace_before_newline(properly_flipped_str)
monospaced_str = '```\n{}\n```'.format(trimmed_str) monospaced_str = '```\n{}\n```'.format(trimmed_str)
return monospaced_str return monospaced_str
def guide_with_numbers(board_str: str) -> str: def guide_with_numbers(board_str: str) -> str:
"""Adds numbers and letters on the side of a string without them made out """Adds numbers and letters on the side of a string without them made out
of a board. of a board.
@ -702,12 +570,11 @@ def guide_with_numbers(board_str: str) -> str:
row_str = (' '.join(row_list) + ' 1').replace('\n ', '\n') row_str = (' '.join(row_list) + ' 1').replace('\n ', '\n')
# a, b, c, d, e, f, g, and h are easy to add in. # a, b, c, d, e, f, g, and h are easy to add in.
row_and_col_str = ( row_and_col_str = ' a b c d e f g h \n' + row_str + '\n a b c d e f g h '
' a b c d e f g h \n' + row_str + '\n a b c d e f g h '
)
return row_and_col_str return row_and_col_str
def replace_with_unicode(board_str: str) -> str: def replace_with_unicode(board_str: str) -> str:
"""Replaces the default characters in a board object's string output with """Replaces the default characters in a board object's string output with
Unicode chess characters, e.g., '' instead of 'R.' Unicode chess characters, e.g., '' instead of 'R.'
@ -737,6 +604,7 @@ def replace_with_unicode(board_str: str) -> str:
return replaced_str return replaced_str
def trim_whitespace_before_newline(str_to_trim: str) -> str: def trim_whitespace_before_newline(str_to_trim: str) -> str:
"""Removes any spaces before a newline in a string. """Removes any spaces before a newline in a string.

View file

@ -114,9 +114,11 @@ To make your next move, respond to Chess Bot with
def test_main(self) -> None: def test_main(self) -> None:
with self.mock_config_info({'stockfish_location': '/foo/bar'}): with self.mock_config_info({'stockfish_location': '/foo/bar'}):
self.verify_dialog([ self.verify_dialog(
('start with other user', self.START_RESPONSE), [
('do e4', self.DO_E4_RESPONSE), ('start with other user', self.START_RESPONSE),
('do Ke4', self.DO_KE4_RESPONSE), ('do e4', self.DO_E4_RESPONSE),
('resign', self.RESIGN_RESPONSE), ('do Ke4', self.DO_KE4_RESPONSE),
]) ('resign', self.RESIGN_RESPONSE),
]
)

View file

@ -46,8 +46,10 @@ class ConnectFourBotHandler(GameAdapter):
def __init__(self) -> None: def __init__(self) -> None:
game_name = 'Connect Four' game_name = 'Connect Four'
bot_name = 'connect_four' bot_name = 'connect_four'
move_help_message = '* To make your move during a game, type\n' \ move_help_message = (
'```move <column-number>``` or ```<column-number>```' '* To make your move during a game, type\n'
'```move <column-number>``` or ```<column-number>```'
)
move_regex = '(move ([1-7])$)|(([1-7])$)' move_regex = '(move ([1-7])$)|(([1-7])$)'
model = ConnectFourModel model = ConnectFourModel
gameMessageHandler = ConnectFourMessageHandler gameMessageHandler = ConnectFourMessageHandler
@ -61,7 +63,7 @@ class ConnectFourBotHandler(GameAdapter):
model, model,
gameMessageHandler, gameMessageHandler,
rules, rules,
max_players=2 max_players=2,
) )

View file

@ -17,7 +17,7 @@ class ConnectFourModel:
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0] [0, 0, 0, 0, 0, 0, 0],
] ]
self.current_board = self.blank_board self.current_board = self.blank_board
@ -27,10 +27,7 @@ class ConnectFourModel:
def get_column(self, col): def get_column(self, col):
# We use this in tests. # We use this in tests.
return [ return [self.current_board[i][col] for i in range(6)]
self.current_board[i][col]
for i in range(6)
]
def validate_move(self, column_number): def validate_move(self, column_number):
if column_number < 0 or column_number > 6: if column_number < 0 or column_number > 6:
@ -76,8 +73,12 @@ class ConnectFourModel:
for row in range(0, 6): for row in range(0, 6):
for column in range(0, 4): for column in range(0, 4):
horizontal_sum = board[row][column] + board[row][column + 1] + \ horizontal_sum = (
board[row][column + 2] + board[row][column + 3] board[row][column]
+ board[row][column + 1]
+ board[row][column + 2]
+ board[row][column + 3]
)
if horizontal_sum == -4: if horizontal_sum == -4:
return -1 return -1
elif horizontal_sum == 4: elif horizontal_sum == 4:
@ -90,8 +91,12 @@ class ConnectFourModel:
for row in range(0, 3): for row in range(0, 3):
for column in range(0, 7): for column in range(0, 7):
vertical_sum = board[row][column] + board[row + 1][column] + \ vertical_sum = (
board[row + 2][column] + board[row + 3][column] board[row][column]
+ board[row + 1][column]
+ board[row + 2][column]
+ board[row + 3][column]
)
if vertical_sum == -4: if vertical_sum == -4:
return -1 return -1
elif vertical_sum == 4: elif vertical_sum == 4:
@ -106,8 +111,12 @@ class ConnectFourModel:
# Major Diagonl Sum # Major Diagonl Sum
for row in range(0, 3): for row in range(0, 3):
for column in range(0, 4): for column in range(0, 4):
major_diagonal_sum = board[row][column] + board[row + 1][column + 1] + \ major_diagonal_sum = (
board[row + 2][column + 2] + board[row + 3][column + 3] board[row][column]
+ board[row + 1][column + 1]
+ board[row + 2][column + 2]
+ board[row + 3][column + 3]
)
if major_diagonal_sum == -4: if major_diagonal_sum == -4:
return -1 return -1
elif major_diagonal_sum == 4: elif major_diagonal_sum == 4:
@ -116,8 +125,12 @@ class ConnectFourModel:
# Minor Diagonal Sum # Minor Diagonal Sum
for row in range(3, 6): for row in range(3, 6):
for column in range(0, 4): for column in range(0, 4):
minor_diagonal_sum = board[row][column] + board[row - 1][column + 1] + \ minor_diagonal_sum = (
board[row - 2][column + 2] + board[row - 3][column + 3] board[row][column]
+ board[row - 1][column + 1]
+ board[row - 2][column + 2]
+ board[row - 3][column + 3]
)
if minor_diagonal_sum == -4: if minor_diagonal_sum == -4:
return -1 return -1
elif minor_diagonal_sum == 4: elif minor_diagonal_sum == 4:
@ -132,9 +145,11 @@ class ConnectFourModel:
if top_row_multiple != 0: if top_row_multiple != 0:
return 'draw' return 'draw'
winner = get_horizontal_wins(self.current_board) + \ winner = (
get_vertical_wins(self.current_board) + \ get_horizontal_wins(self.current_board)
get_diagonal_wins(self.current_board) + get_vertical_wins(self.current_board)
+ get_diagonal_wins(self.current_board)
)
if winner == 1: if winner == 1:
return first_player return first_player

View file

@ -9,20 +9,19 @@ class TestConnectFourBot(BotTestCase, DefaultTests):
bot_name = 'connect_four' bot_name = 'connect_four'
def make_request_message( def make_request_message(
self, self, content: str, user: str = 'foo@example.com', user_name: str = 'foo'
content: str,
user: str = 'foo@example.com',
user_name: str = 'foo'
) -> Dict[str, str]: ) -> Dict[str, str]:
message = dict( message = dict(sender_email=user, content=content, sender_full_name=user_name)
sender_email=user,
content=content,
sender_full_name=user_name
)
return message return message
# Function that serves similar purpose to BotTestCase.verify_dialog, but allows for multiple responses to be handled # Function that serves similar purpose to BotTestCase.verify_dialog, but allows for multiple responses to be handled
def verify_response(self, request: str, expected_response: str, response_number: int, user: str = 'foo@example.com') -> None: def verify_response(
self,
request: str,
expected_response: str,
response_number: int,
user: str = 'foo@example.com',
) -> None:
''' '''
This function serves a similar purpose This function serves a similar purpose
to BotTestCase.verify_dialog, but allows to BotTestCase.verify_dialog, but allows
@ -36,11 +35,7 @@ class TestConnectFourBot(BotTestCase, DefaultTests):
bot.handle_message(message, bot_handler) bot.handle_message(message, bot_handler)
responses = [ responses = [message for (method, message) in bot_handler.transcript]
message
for (method, message)
in bot_handler.transcript
]
first_response = responses[response_number] first_response = responses[response_number]
self.assertEqual(expected_response, first_response['content']) self.assertEqual(expected_response, first_response['content'])
@ -73,7 +68,9 @@ class TestConnectFourBot(BotTestCase, DefaultTests):
self.verify_response('help', self.help_message(), 0) self.verify_response('help', self.help_message(), 0)
def test_game_message_handler_responses(self) -> None: def test_game_message_handler_responses(self) -> None:
board = ':one: :two: :three: :four: :five: :six: :seven:\n\n' + '\ board = (
':one: :two: :three: :four: :five: :six: :seven:\n\n'
+ '\
:white_circle: :white_circle: :white_circle: :white_circle: \ :white_circle: :white_circle: :white_circle: :white_circle: \
:white_circle: :white_circle: :white_circle: \n\n\ :white_circle: :white_circle: :white_circle: \n\n\
:white_circle: :white_circle: :white_circle: :white_circle: \ :white_circle: :white_circle: :white_circle: :white_circle: \
@ -86,16 +83,18 @@ class TestConnectFourBot(BotTestCase, DefaultTests):
:white_circle: :white_circle: \n\n\ :white_circle: :white_circle: \n\n\
:blue_circle: :red_circle: :white_circle: :white_circle: :white_circle: \ :blue_circle: :red_circle: :white_circle: :white_circle: :white_circle: \
:white_circle: :white_circle: ' :white_circle: :white_circle: '
)
bot, bot_handler = self._get_handlers() bot, bot_handler = self._get_handlers()
self.assertEqual(bot.gameMessageHandler.parse_board( self.assertEqual(bot.gameMessageHandler.parse_board(self.almost_win_board), board)
self.almost_win_board), board) self.assertEqual(bot.gameMessageHandler.get_player_color(1), ':red_circle:')
self.assertEqual( self.assertEqual(
bot.gameMessageHandler.get_player_color(1), ':red_circle:') bot.gameMessageHandler.alert_move_message('foo', 'move 6'), 'foo moved in column 6'
self.assertEqual(bot.gameMessageHandler.alert_move_message( )
'foo', 'move 6'), 'foo moved in column 6') self.assertEqual(
self.assertEqual(bot.gameMessageHandler.game_start_message( bot.gameMessageHandler.game_start_message(),
), 'Type `move <column-number>` or `<column-number>` to place a token.\n\ 'Type `move <column-number>` or `<column-number>` to place a token.\n\
The first player to get 4 in a row wins!\n Good Luck!') The first player to get 4 in a row wins!\n Good Luck!',
)
blank_board = [ blank_board = [
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
@ -103,7 +102,8 @@ The first player to get 4 in a row wins!\n Good Luck!')
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]] [0, 0, 0, 0, 0, 0, 0],
]
almost_win_board = [ almost_win_board = [
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
@ -111,7 +111,8 @@ The first player to get 4 in a row wins!\n Good Luck!')
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[1, -1, 0, 0, 0, 0, 0], [1, -1, 0, 0, 0, 0, 0],
[1, -1, 0, 0, 0, 0, 0], [1, -1, 0, 0, 0, 0, 0],
[1, -1, 0, 0, 0, 0, 0]] [1, -1, 0, 0, 0, 0, 0],
]
almost_draw_board = [ almost_draw_board = [
[1, -1, 1, -1, 1, -1, 0], [1, -1, 1, -1, 1, -1, 0],
@ -119,13 +120,12 @@ The first player to get 4 in a row wins!\n Good Luck!')
[0, 0, 0, 0, 0, 0, -1], [0, 0, 0, 0, 0, 0, -1],
[0, 0, 0, 0, 0, 0, 1], [0, 0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 0, -1], [0, 0, 0, 0, 0, 0, -1],
[0, 0, 0, 0, 0, 0, 1]] [0, 0, 0, 0, 0, 0, 1],
]
def test_connect_four_logic(self) -> None: def test_connect_four_logic(self) -> None:
def confirmAvailableMoves( def confirmAvailableMoves(
good_moves: List[int], good_moves: List[int], bad_moves: List[int], board: List[List[int]]
bad_moves: List[int],
board: List[List[int]]
) -> None: ) -> None:
connectFourModel.update_board(board) connectFourModel.update_board(board)
@ -139,18 +139,16 @@ The first player to get 4 in a row wins!\n Good Luck!')
column_number: int, column_number: int,
token_number: int, token_number: int,
initial_board: List[List[int]], initial_board: List[List[int]],
final_board: List[List[int]] final_board: List[List[int]],
) -> None: ) -> None:
connectFourModel.update_board(initial_board) connectFourModel.update_board(initial_board)
test_board = connectFourModel.make_move( test_board = connectFourModel.make_move('move ' + str(column_number), token_number)
'move ' + str(column_number), token_number)
self.assertEqual(test_board, final_board) self.assertEqual(test_board, final_board)
def confirmGameOver(board: List[List[int]], result: str) -> None: def confirmGameOver(board: List[List[int]], result: str) -> None:
connectFourModel.update_board(board) connectFourModel.update_board(board)
game_over = connectFourModel.determine_game_over( game_over = connectFourModel.determine_game_over(['first_player', 'second_player'])
['first_player', 'second_player'])
self.assertEqual(game_over, result) self.assertEqual(game_over, result)
@ -170,7 +168,8 @@ The first player to get 4 in a row wins!\n Good Luck!')
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]] [0, 0, 0, 0, 0, 0, 0],
]
full_board = [ full_board = [
[1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1],
@ -178,7 +177,8 @@ The first player to get 4 in a row wins!\n Good Luck!')
[1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1]] [1, 1, 1, 1, 1, 1, 1],
]
single_column_board = [ single_column_board = [
[1, 1, 1, 0, 1, 1, 1], [1, 1, 1, 0, 1, 1, 1],
@ -186,7 +186,8 @@ The first player to get 4 in a row wins!\n Good Luck!')
[1, 1, 1, 0, 1, 1, 1], [1, 1, 1, 0, 1, 1, 1],
[1, 1, 1, 0, 1, 1, 1], [1, 1, 1, 0, 1, 1, 1],
[1, 1, 1, 0, 1, 1, 1], [1, 1, 1, 0, 1, 1, 1],
[1, 1, 1, 0, 1, 1, 1]] [1, 1, 1, 0, 1, 1, 1],
]
diagonal_board = [ diagonal_board = [
[0, 0, 0, 0, 0, 0, 1], [0, 0, 0, 0, 0, 0, 1],
@ -194,7 +195,8 @@ The first player to get 4 in a row wins!\n Good Luck!')
[0, 0, 0, 0, 1, 1, 1], [0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 1, 1, 1, 1], [0, 0, 0, 1, 1, 1, 1],
[0, 0, 1, 1, 1, 1, 1], [0, 0, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1]] [0, 1, 1, 1, 1, 1, 1],
]
# Winning Board Setups # Winning Board Setups
# Each array if consists of two arrays: # Each array if consists of two arrays:
@ -204,190 +206,222 @@ The first player to get 4 in a row wins!\n Good Luck!')
# for simplicity (random -1 and 1s could be added) # for simplicity (random -1 and 1s could be added)
horizontal_win_boards = [ horizontal_win_boards = [
[ [
[[0, 0, 0, 0, 0, 0, 0], [
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 0, 0, 0]], [0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 0, 0, 0],
[[0, 0, 0, 1, 1, 1, 1], ],
[0, 0, 0, 0, 0, 0, 0], [
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 1, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], ],
[0, 0, 0, 0, 0, 0, 0], [
[0, 0, 1, 1, 1, 1, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]] [0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
], ],
[ [
[[0, 0, 0, 0, 0, 0, 0], [
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[-1, -1, -1, -1, 0, 0, 0]], [0, 0, 0, 0, 0, 0, 0],
[-1, -1, -1, -1, 0, 0, 0],
[[0, 0, 0, -1, -1, -1, -1], ],
[0, 0, 0, 0, 0, 0, 0], [
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, -1, -1, -1, -1],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], ],
[0, 0, 0, 0, 0, 0, 0], [
[0, 0, -1, -1, -1, -1, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]] [0, 0, 0, 0, 0, 0, 0],
] [0, 0, -1, -1, -1, -1, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
],
] ]
vertical_win_boards = [ vertical_win_boards = [
[ [
[[0, 0, 0, 0, 0, 0, 0], [
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0]], [1, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0],
[[0, 0, 0, 0, 0, 0, 1], ],
[0, 0, 0, 0, 0, 0, 1], [
[0, 0, 0, 0, 0, 0, 1], [0, 0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 0, 1], [0, 0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 0, 0]], [0, 0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 0, 0],
[[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0], ],
[0, 0, 0, 1, 0, 0, 0], [
[0, 0, 0, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0], [0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]] [0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
], ],
[ [
[[0, 0, 0, 0, 0, 0, 0], [
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[-1, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[-1, 0, 0, 0, 0, 0, 0], [-1, 0, 0, 0, 0, 0, 0],
[-1, 0, 0, 0, 0, 0, 0], [-1, 0, 0, 0, 0, 0, 0],
[-1, 0, 0, 0, 0, 0, 0]], [-1, 0, 0, 0, 0, 0, 0],
[-1, 0, 0, 0, 0, 0, 0],
[[0, 0, 0, 0, 0, 0, -1], ],
[0, 0, 0, 0, 0, 0, -1], [
[0, 0, 0, 0, 0, 0, -1], [0, 0, 0, 0, 0, 0, -1],
[0, 0, 0, 0, 0, 0, -1], [0, 0, 0, 0, 0, 0, -1],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, -1],
[0, 0, 0, 0, 0, 0, 0]], [0, 0, 0, 0, 0, 0, -1],
[0, 0, 0, 0, 0, 0, 0],
[[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, -1, 0, 0, 0], ],
[0, 0, 0, -1, 0, 0, 0], [
[0, 0, 0, -1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, -1, 0, 0, 0], [0, 0, 0, -1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]] [0, 0, 0, -1, 0, 0, 0],
] [0, 0, 0, -1, 0, 0, 0],
[0, 0, 0, -1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
],
] ]
major_diagonal_win_boards = [ major_diagonal_win_boards = [
[ [
[[1, 0, 0, 0, 0, 0, 0], [
[0, 1, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0], [0, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0], [0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[[0, 0, 0, 0, 0, 0, 0], ],
[0, 0, 0, 0, 0, 0, 0], [
[0, 0, 0, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0], [0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 1]], [0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 0],
[[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 1],
[0, 0, 1, 0, 0, 0, 0], ],
[0, 0, 0, 1, 0, 0, 0], [
[0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0], [0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]] [0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 0],
],
], ],
[ [
[[-1, 0, 0, 0, 0, 0, 0], [
[0, -1, 0, 0, 0, 0, 0], [-1, 0, 0, 0, 0, 0, 0],
[0, 0, -1, 0, 0, 0, 0], [0, -1, 0, 0, 0, 0, 0],
[0, 0, 0, -1, 0, 0, 0], [0, 0, -1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, -1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[[0, 0, 0, 0, 0, 0, 0], ],
[0, 0, 0, 0, 0, 0, 0], [
[0, 0, 0, -1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, -1, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, -1, 0], [0, 0, 0, -1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, -1]], [0, 0, 0, 0, -1, 0, 0],
[0, 0, 0, 0, 0, -1, 0],
[[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, -1],
[0, 0, -1, 0, 0, 0, 0], ],
[0, 0, 0, -1, 0, 0, 0], [
[0, 0, 0, 0, -1, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, -1, 0], [0, 0, -1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]] [0, 0, 0, -1, 0, 0, 0],
] [0, 0, 0, 0, -1, 0, 0],
[0, 0, 0, 0, 0, -1, 0],
[0, 0, 0, 0, 0, 0, 0],
],
],
] ]
minor_diagonal_win_boards = [ minor_diagonal_win_boards = [
[ [
[[0, 0, 0, 0, 0, 0, 0], [
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0], [0, 0, 0, 1, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0], [0, 0, 1, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0]], [0, 1, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0],
[[0, 0, 0, 0, 0, 0, 1], ],
[0, 0, 0, 0, 0, 1, 0], [
[0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 1],
[0, 0, 0, 1, 0, 0, 0], [0, 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 0]], [0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 1, 0, 0], ],
[0, 0, 0, 1, 0, 0, 0], [
[0, 0, 1, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0], [0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 0, 0]] [0, 0, 0, 1, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
], ],
[ [
[[0, 0, 0, 0, 0, 0, 0], [
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, -1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, -1, 0, 0, 0, 0], [0, 0, 0, -1, 0, 0, 0],
[0, -1, 0, 0, 0, 0, 0], [0, 0, -1, 0, 0, 0, 0],
[-1, 0, 0, 0, 0, 0, 0]], [0, -1, 0, 0, 0, 0, 0],
[-1, 0, 0, 0, 0, 0, 0],
[[0, 0, 0, 0, 0, 0, -1], ],
[0, 0, 0, 0, 0, -1, 0], [
[0, 0, 0, 0, -1, 0, 0], [0, 0, 0, 0, 0, 0, -1],
[0, 0, 0, -1, 0, 0, 0], [0, 0, 0, 0, 0, -1, 0],
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, -1, 0, 0],
[0, 0, 0, 0, 0, 0, 0]], [0, 0, 0, -1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, -1, 0, 0], ],
[0, 0, 0, -1, 0, 0, 0], [
[0, 0, -1, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[0, -1, 0, 0, 0, 0, 0], [0, 0, 0, 0, -1, 0, 0],
[0, 0, 0, 0, 0, 0, 0]] [0, 0, 0, -1, 0, 0, 0],
] [0, 0, -1, 0, 0, 0, 0],
[0, -1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
],
],
] ]
# Test Move Validation Logic # Test Move Validation Logic
@ -397,8 +431,7 @@ The first player to get 4 in a row wins!\n Good Luck!')
# Test Available Move Logic # Test Available Move Logic
connectFourModel.update_board(blank_board) connectFourModel.update_board(blank_board)
self.assertEqual(connectFourModel.available_moves(), self.assertEqual(connectFourModel.available_moves(), [0, 1, 2, 3, 4, 5, 6])
[0, 1, 2, 3, 4, 5, 6])
connectFourModel.update_board(single_column_board) connectFourModel.update_board(single_column_board)
self.assertEqual(connectFourModel.available_moves(), [3]) self.assertEqual(connectFourModel.available_moves(), [3])
@ -407,69 +440,117 @@ The first player to get 4 in a row wins!\n Good Luck!')
self.assertEqual(connectFourModel.available_moves(), []) self.assertEqual(connectFourModel.available_moves(), [])
# Test Move Logic # Test Move Logic
confirmMove(1, 0, blank_board, confirmMove(
[[0, 0, 0, 0, 0, 0, 0], 1,
[0, 0, 0, 0, 0, 0, 0], 0,
[0, 0, 0, 0, 0, 0, 0], blank_board,
[0, 0, 0, 0, 0, 0, 0], [
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0]]) [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0],
],
)
confirmMove(1, 1, blank_board, confirmMove(
[[0, 0, 0, 0, 0, 0, 0], 1,
[0, 0, 0, 0, 0, 0, 0], 1,
[0, 0, 0, 0, 0, 0, 0], blank_board,
[0, 0, 0, 0, 0, 0, 0], [
[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0],
[-1, 0, 0, 0, 0, 0, 0]]) [0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[-1, 0, 0, 0, 0, 0, 0],
],
)
confirmMove(1, 0, diagonal_board, confirmMove(
[[0, 0, 0, 0, 0, 0, 1], 1,
[0, 0, 0, 0, 0, 1, 1], 0,
[0, 0, 0, 0, 1, 1, 1], diagonal_board,
[0, 0, 0, 1, 1, 1, 1], [
[0, 0, 1, 1, 1, 1, 1], [0, 0, 0, 0, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1]]) [0, 0, 0, 0, 0, 1, 1],
[0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 1, 1, 1, 1],
[0, 0, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1],
],
)
confirmMove(2, 0, diagonal_board, confirmMove(
[[0, 0, 0, 0, 0, 0, 1], 2,
[0, 0, 0, 0, 0, 1, 1], 0,
[0, 0, 0, 0, 1, 1, 1], diagonal_board,
[0, 0, 0, 1, 1, 1, 1], [
[0, 1, 1, 1, 1, 1, 1], [0, 0, 0, 0, 0, 0, 1],
[0, 1, 1, 1, 1, 1, 1]]) [0, 0, 0, 0, 0, 1, 1],
[0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1],
],
)
confirmMove(3, 0, diagonal_board, confirmMove(
[[0, 0, 0, 0, 0, 0, 1], 3,
[0, 0, 0, 0, 0, 1, 1], 0,
[0, 0, 0, 0, 1, 1, 1], diagonal_board,
[0, 0, 1, 1, 1, 1, 1], [
[0, 0, 1, 1, 1, 1, 1], [0, 0, 0, 0, 0, 0, 1],
[0, 1, 1, 1, 1, 1, 1]]) [0, 0, 0, 0, 0, 1, 1],
[0, 0, 0, 0, 1, 1, 1],
[0, 0, 1, 1, 1, 1, 1],
[0, 0, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1],
],
)
confirmMove(4, 0, diagonal_board, confirmMove(
[[0, 0, 0, 0, 0, 0, 1], 4,
[0, 0, 0, 0, 0, 1, 1], 0,
[0, 0, 0, 1, 1, 1, 1], diagonal_board,
[0, 0, 0, 1, 1, 1, 1], [
[0, 0, 1, 1, 1, 1, 1], [0, 0, 0, 0, 0, 0, 1],
[0, 1, 1, 1, 1, 1, 1]]) [0, 0, 0, 0, 0, 1, 1],
[0, 0, 0, 1, 1, 1, 1],
[0, 0, 0, 1, 1, 1, 1],
[0, 0, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1],
],
)
confirmMove(5, 0, diagonal_board, confirmMove(
[[0, 0, 0, 0, 0, 0, 1], 5,
[0, 0, 0, 0, 1, 1, 1], 0,
[0, 0, 0, 0, 1, 1, 1], diagonal_board,
[0, 0, 0, 1, 1, 1, 1], [
[0, 0, 1, 1, 1, 1, 1], [0, 0, 0, 0, 0, 0, 1],
[0, 1, 1, 1, 1, 1, 1]]) [0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 1, 1, 1, 1],
[0, 0, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1],
],
)
confirmMove(6, 0, diagonal_board, confirmMove(
[[0, 0, 0, 0, 0, 1, 1], 6,
[0, 0, 0, 0, 0, 1, 1], 0,
[0, 0, 0, 0, 1, 1, 1], diagonal_board,
[0, 0, 0, 1, 1, 1, 1], [
[0, 0, 1, 1, 1, 1, 1], [0, 0, 0, 0, 0, 1, 1],
[0, 1, 1, 1, 1, 1, 1]]) [0, 0, 0, 0, 0, 1, 1],
[0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 1, 1, 1, 1],
[0, 0, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1],
],
)
# Test Game Over Logic: # Test Game Over Logic:
confirmGameOver(blank_board, '') confirmGameOver(blank_board, '')

View file

@ -15,14 +15,17 @@ def is_float(value: Any) -> bool:
except ValueError: except ValueError:
return False return False
# Rounds the number 'x' to 'digits' significant digits. # Rounds the number 'x' to 'digits' significant digits.
# A normal 'round()' would round the number to an absolute amount of # A normal 'round()' would round the number to an absolute amount of
# fractional decimals, e.g. 0.00045 would become 0.0. # fractional decimals, e.g. 0.00045 would become 0.0.
# 'round_to()' rounds only the digits that are not 0. # 'round_to()' rounds only the digits that are not 0.
# 0.00045 would then become 0.0005. # 0.00045 would then become 0.0005.
def round_to(x: float, digits: int) -> float: def round_to(x: float, digits: int) -> float:
return round(x, digits-int(floor(log10(abs(x))))) return round(x, digits - int(floor(log10(abs(x)))))
class ConverterHandler: class ConverterHandler:
''' '''
@ -49,6 +52,7 @@ class ConverterHandler:
bot_response = get_bot_converter_response(message, bot_handler) bot_response = get_bot_converter_response(message, bot_handler)
bot_handler.send_reply(message, bot_response) bot_handler.send_reply(message, bot_response)
def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler) -> str: def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler) -> str:
content = message['content'] content = message['content']
@ -78,10 +82,10 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler)
for key, exp in utils.PREFIXES.items(): for key, exp in utils.PREFIXES.items():
if unit_from.startswith(key): if unit_from.startswith(key):
exponent += exp exponent += exp
unit_from = unit_from[len(key):] unit_from = unit_from[len(key) :]
if unit_to.startswith(key): if unit_to.startswith(key):
exponent -= exp exponent -= exp
unit_to = unit_to[len(key):] unit_to = unit_to[len(key) :]
uf_to_std = utils.UNITS.get(unit_from, []) # type: List[Any] uf_to_std = utils.UNITS.get(unit_from, []) # type: List[Any]
ut_to_std = utils.UNITS.get(unit_to, []) # type: List[Any] ut_to_std = utils.UNITS.get(unit_to, []) # type: List[Any]
@ -97,8 +101,13 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler)
if uf_to_std[2] != ut_to_std[2]: if uf_to_std[2] != ut_to_std[2]:
unit_from = unit_from.capitalize() if uf_to_std[2] == 'kelvin' else unit_from unit_from = unit_from.capitalize() if uf_to_std[2] == 'kelvin' else unit_from
results.append( results.append(
'`' + unit_to.capitalize() + '` and `' + unit_from + '`' '`'
+ ' are not from the same category. ' + utils.QUICK_HELP + unit_to.capitalize()
+ '` and `'
+ unit_from
+ '`'
+ ' are not from the same category. '
+ utils.QUICK_HELP
) )
continue continue
@ -114,10 +123,11 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler)
number_res *= 10 ** exponent number_res *= 10 ** exponent
number_res = round_to(number_res, 7) number_res = round_to(number_res, 7)
results.append('{} {} = {} {}'.format(number, results.append(
words[convert_index + 2], '{} {} = {} {}'.format(
number_res, number, words[convert_index + 2], number_res, words[convert_index + 3]
words[convert_index + 3])) )
)
else: else:
results.append('Too few arguments given. ' + utils.QUICK_HELP) results.append('Too few arguments given. ' + utils.QUICK_HELP)
@ -128,4 +138,5 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler)
return new_content return new_content
handler_class = ConverterHandler handler_class = ConverterHandler

View file

@ -7,25 +7,41 @@ class TestConverterBot(BotTestCase, DefaultTests):
def test_bot(self) -> None: def test_bot(self) -> None:
dialog = [ dialog = [
("", 'Too few arguments given. Enter `@convert help` ' (
'for help on using the converter.\n'), "",
("foo bar", 'Too few arguments given. Enter `@convert help` ' 'Too few arguments given. Enter `@convert help` '
'for help on using the converter.\n'), 'for help on using the converter.\n',
),
(
"foo bar",
'Too few arguments given. Enter `@convert help` '
'for help on using the converter.\n',
),
("2 m cm", "2 m = 200.0 cm\n"), ("2 m cm", "2 m = 200.0 cm\n"),
("12.0 celsius fahrenheit", "12.0 celsius = 53.600054 fahrenheit\n"), ("12.0 celsius fahrenheit", "12.0 celsius = 53.600054 fahrenheit\n"),
("0.002 kilometer millimile", "0.002 kilometer = 1.2427424 millimile\n"), ("0.002 kilometer millimile", "0.002 kilometer = 1.2427424 millimile\n"),
("3 megabyte kilobit", "3 megabyte = 24576.0 kilobit\n"), ("3 megabyte kilobit", "3 megabyte = 24576.0 kilobit\n"),
("foo m cm", "`foo` is not a valid number. " + utils.QUICK_HELP + "\n"), ("foo m cm", "`foo` is not a valid number. " + utils.QUICK_HELP + "\n"),
("@convert help", "1. conversion: Too few arguments given. " (
"Enter `@convert help` for help on using the converter.\n" "@convert help",
"2. conversion: " + utils.HELP_MESSAGE + "\n"), "1. conversion: Too few arguments given. "
("2 celsius kilometer", "`Meter` and `Celsius` are not from the same category. " "Enter `@convert help` for help on using the converter.\n"
"Enter `@convert help` for help on using the converter.\n"), "2. conversion: " + utils.HELP_MESSAGE + "\n",
("2 foo kilometer", "`foo` is not a valid unit." ),
" Enter `@convert help` for help on using the converter.\n"), (
("2 kilometer foo", "`foo` is not a valid unit." "2 celsius kilometer",
"Enter `@convert help` for help on using the converter.\n"), "`Meter` and `Celsius` are not from the same category. "
"Enter `@convert help` for help on using the converter.\n",
),
(
"2 foo kilometer",
"`foo` is not a valid unit."
" Enter `@convert help` for help on using the converter.\n",
),
(
"2 kilometer foo",
"`foo` is not a valid unit."
"Enter `@convert help` for help on using the converter.\n",
),
] ]
self.verify_dialog(dialog) self.verify_dialog(dialog)

View file

@ -2,145 +2,153 @@
# An entry consists of the unit's name, a constant number and a constant # An entry consists of the unit's name, a constant number and a constant
# factor that need to be added and multiplied to convert the unit into # factor that need to be added and multiplied to convert the unit into
# the base unit in the last parameter. # the base unit in the last parameter.
UNITS = {'bit': [0, 1, 'bit'], UNITS = {
'byte': [0, 8, 'bit'], 'bit': [0, 1, 'bit'],
'cubic-centimeter': [0, 0.000001, 'cubic-meter'], 'byte': [0, 8, 'bit'],
'cubic-decimeter': [0, 0.001, 'cubic-meter'], 'cubic-centimeter': [0, 0.000001, 'cubic-meter'],
'liter': [0, 0.001, 'cubic-meter'], 'cubic-decimeter': [0, 0.001, 'cubic-meter'],
'cubic-meter': [0, 1, 'cubic-meter'], 'liter': [0, 0.001, 'cubic-meter'],
'cubic-inch': [0, 0.000016387064, 'cubic-meter'], 'cubic-meter': [0, 1, 'cubic-meter'],
'fluid-ounce': [0, 0.000029574, 'cubic-meter'], 'cubic-inch': [0, 0.000016387064, 'cubic-meter'],
'cubic-foot': [0, 0.028316846592, 'cubic-meter'], 'fluid-ounce': [0, 0.000029574, 'cubic-meter'],
'cubic-yard': [0, 0.764554857984, 'cubic-meter'], 'cubic-foot': [0, 0.028316846592, 'cubic-meter'],
'teaspoon': [0, 0.0000049289216, 'cubic-meter'], 'cubic-yard': [0, 0.764554857984, 'cubic-meter'],
'tablespoon': [0, 0.000014787, 'cubic-meter'], 'teaspoon': [0, 0.0000049289216, 'cubic-meter'],
'cup': [0, 0.00023658823648491, 'cubic-meter'], 'tablespoon': [0, 0.000014787, 'cubic-meter'],
'gram': [0, 1, 'gram'], 'cup': [0, 0.00023658823648491, 'cubic-meter'],
'kilogram': [0, 1000, 'gram'], 'gram': [0, 1, 'gram'],
'ton': [0, 1000000, 'gram'], 'kilogram': [0, 1000, 'gram'],
'ounce': [0, 28.349523125, 'gram'], 'ton': [0, 1000000, 'gram'],
'pound': [0, 453.59237, 'gram'], 'ounce': [0, 28.349523125, 'gram'],
'kelvin': [0, 1, 'kelvin'], 'pound': [0, 453.59237, 'gram'],
'celsius': [273.15, 1, 'kelvin'], 'kelvin': [0, 1, 'kelvin'],
'fahrenheit': [255.372222, 0.555555, 'kelvin'], 'celsius': [273.15, 1, 'kelvin'],
'centimeter': [0, 0.01, 'meter'], 'fahrenheit': [255.372222, 0.555555, 'kelvin'],
'decimeter': [0, 0.1, 'meter'], 'centimeter': [0, 0.01, 'meter'],
'meter': [0, 1, 'meter'], 'decimeter': [0, 0.1, 'meter'],
'kilometer': [0, 1000, 'meter'], 'meter': [0, 1, 'meter'],
'inch': [0, 0.0254, 'meter'], 'kilometer': [0, 1000, 'meter'],
'foot': [0, 0.3048, 'meter'], 'inch': [0, 0.0254, 'meter'],
'yard': [0, 0.9144, 'meter'], 'foot': [0, 0.3048, 'meter'],
'mile': [0, 1609.344, 'meter'], 'yard': [0, 0.9144, 'meter'],
'nautical-mile': [0, 1852, 'meter'], 'mile': [0, 1609.344, 'meter'],
'square-centimeter': [0, 0.0001, 'square-meter'], 'nautical-mile': [0, 1852, 'meter'],
'square-decimeter': [0, 0.01, 'square-meter'], 'square-centimeter': [0, 0.0001, 'square-meter'],
'square-meter': [0, 1, 'square-meter'], 'square-decimeter': [0, 0.01, 'square-meter'],
'square-kilometer': [0, 1000000, 'square-meter'], 'square-meter': [0, 1, 'square-meter'],
'square-inch': [0, 0.00064516, 'square-meter'], 'square-kilometer': [0, 1000000, 'square-meter'],
'square-foot': [0, 0.09290304, 'square-meter'], 'square-inch': [0, 0.00064516, 'square-meter'],
'square-yard': [0, 0.83612736, 'square-meter'], 'square-foot': [0, 0.09290304, 'square-meter'],
'square-mile': [0, 2589988.110336, 'square-meter'], 'square-yard': [0, 0.83612736, 'square-meter'],
'are': [0, 100, 'square-meter'], 'square-mile': [0, 2589988.110336, 'square-meter'],
'hectare': [0, 10000, 'square-meter'], 'are': [0, 100, 'square-meter'],
'acre': [0, 4046.8564224, 'square-meter']} 'hectare': [0, 10000, 'square-meter'],
'acre': [0, 4046.8564224, 'square-meter'],
}
PREFIXES = {'atto': -18, PREFIXES = {
'femto': -15, 'atto': -18,
'pico': -12, 'femto': -15,
'nano': -9, 'pico': -12,
'micro': -6, 'nano': -9,
'milli': -3, 'micro': -6,
'centi': -2, 'milli': -3,
'deci': -1, 'centi': -2,
'deca': 1, 'deci': -1,
'hecto': 2, 'deca': 1,
'kilo': 3, 'hecto': 2,
'mega': 6, 'kilo': 3,
'giga': 9, 'mega': 6,
'tera': 12, 'giga': 9,
'peta': 15, 'tera': 12,
'exa': 18} 'peta': 15,
'exa': 18,
}
ALIASES = {'a': 'are', ALIASES = {
'ac': 'acre', 'a': 'are',
'c': 'celsius', 'ac': 'acre',
'cm': 'centimeter', 'c': 'celsius',
'cm2': 'square-centimeter', 'cm': 'centimeter',
'cm3': 'cubic-centimeter', 'cm2': 'square-centimeter',
'cm^2': 'square-centimeter', 'cm3': 'cubic-centimeter',
'cm^3': 'cubic-centimeter', 'cm^2': 'square-centimeter',
'dm': 'decimeter', 'cm^3': 'cubic-centimeter',
'dm2': 'square-decimeter', 'dm': 'decimeter',
'dm3': 'cubic-decimeter', 'dm2': 'square-decimeter',
'dm^2': 'square-decimeter', 'dm3': 'cubic-decimeter',
'dm^3': 'cubic-decimeter', 'dm^2': 'square-decimeter',
'f': 'fahrenheit', 'dm^3': 'cubic-decimeter',
'fl-oz': 'fluid-ounce', 'f': 'fahrenheit',
'ft': 'foot', 'fl-oz': 'fluid-ounce',
'ft2': 'square-foot', 'ft': 'foot',
'ft3': 'cubic-foot', 'ft2': 'square-foot',
'ft^2': 'square-foot', 'ft3': 'cubic-foot',
'ft^3': 'cubic-foot', 'ft^2': 'square-foot',
'g': 'gram', 'ft^3': 'cubic-foot',
'ha': 'hectare', 'g': 'gram',
'in': 'inch', 'ha': 'hectare',
'in2': 'square-inch', 'in': 'inch',
'in3': 'cubic-inch', 'in2': 'square-inch',
'in^2': 'square-inch', 'in3': 'cubic-inch',
'in^3': 'cubic-inch', 'in^2': 'square-inch',
'k': 'kelvin', 'in^3': 'cubic-inch',
'kg': 'kilogram', 'k': 'kelvin',
'km': 'kilometer', 'kg': 'kilogram',
'km2': 'square-kilometer', 'km': 'kilometer',
'km^2': 'square-kilometer', 'km2': 'square-kilometer',
'l': 'liter', 'km^2': 'square-kilometer',
'lb': 'pound', 'l': 'liter',
'm': 'meter', 'lb': 'pound',
'm2': 'square-meter', 'm': 'meter',
'm3': 'cubic-meter', 'm2': 'square-meter',
'm^2': 'square-meter', 'm3': 'cubic-meter',
'm^3': 'cubic-meter', 'm^2': 'square-meter',
'mi': 'mile', 'm^3': 'cubic-meter',
'mi2': 'square-mile', 'mi': 'mile',
'mi^2': 'square-mile', 'mi2': 'square-mile',
'nmi': 'nautical-mile', 'mi^2': 'square-mile',
'oz': 'ounce', 'nmi': 'nautical-mile',
't': 'ton', 'oz': 'ounce',
'tbsp': 'tablespoon', 't': 'ton',
'tsp': 'teaspoon', 'tbsp': 'tablespoon',
'y': 'yard', 'tsp': 'teaspoon',
'y2': 'square-yard', 'y': 'yard',
'y3': 'cubic-yard', 'y2': 'square-yard',
'y^2': 'square-yard', 'y3': 'cubic-yard',
'y^3': 'cubic-yard'} 'y^2': 'square-yard',
'y^3': 'cubic-yard',
}
HELP_MESSAGE = ('Converter usage:\n' HELP_MESSAGE = (
'`@convert <number> <unit_from> <unit_to>`\n' 'Converter usage:\n'
'Converts `number` in the unit <unit_from> to ' '`@convert <number> <unit_from> <unit_to>`\n'
'the <unit_to> and prints the result\n' 'Converts `number` in the unit <unit_from> to '
'`number`: integer or floating point number, e.g. 12, 13.05, 0.002\n' 'the <unit_to> and prints the result\n'
'<unit_from> and <unit_to> are two of the following units:\n' '`number`: integer or floating point number, e.g. 12, 13.05, 0.002\n'
'* square-centimeter (cm^2, cm2), square-decimeter (dm^2, dm2), ' '<unit_from> and <unit_to> are two of the following units:\n'
'square-meter (m^2, m2), square-kilometer (km^2, km2),' '* square-centimeter (cm^2, cm2), square-decimeter (dm^2, dm2), '
' square-inch (in^2, in2), square-foot (ft^2, ft2), square-yard (y^2, y2), ' 'square-meter (m^2, m2), square-kilometer (km^2, km2),'
' square-mile(mi^2, mi2), are (a), hectare (ha), acre (ac)\n' ' square-inch (in^2, in2), square-foot (ft^2, ft2), square-yard (y^2, y2), '
'* bit, byte\n' ' square-mile(mi^2, mi2), are (a), hectare (ha), acre (ac)\n'
'* centimeter (cm), decimeter(dm), meter (m),' '* bit, byte\n'
' kilometer (km), inch (in), foot (ft), yard (y),' '* centimeter (cm), decimeter(dm), meter (m),'
' mile (mi), nautical-mile (nmi)\n' ' kilometer (km), inch (in), foot (ft), yard (y),'
'* Kelvin (K), Celsius(C), Fahrenheit (F)\n' ' mile (mi), nautical-mile (nmi)\n'
'* cubic-centimeter (cm^3, cm3), cubic-decimeter (dm^3, dm3), liter (l), ' '* Kelvin (K), Celsius(C), Fahrenheit (F)\n'
'cubic-meter (m^3, m3), cubic-inch (in^3, in3), fluid-ounce (fl-oz), ' '* cubic-centimeter (cm^3, cm3), cubic-decimeter (dm^3, dm3), liter (l), '
'cubic-foot (ft^3, ft3), cubic-yard (y^3, y3)\n' 'cubic-meter (m^3, m3), cubic-inch (in^3, in3), fluid-ounce (fl-oz), '
'* gram (g), kilogram (kg), ton (t), ounce (oz), pound(lb)\n' 'cubic-foot (ft^3, ft3), cubic-yard (y^3, y3)\n'
'* (metric only, U.S. and imperial units differ slightly:) teaspoon (tsp), tablespoon (tbsp), cup\n\n\n' '* gram (g), kilogram (kg), ton (t), ounce (oz), pound(lb)\n'
'Allowed prefixes are:\n' '* (metric only, U.S. and imperial units differ slightly:) teaspoon (tsp), tablespoon (tbsp), cup\n\n\n'
'* atto, pico, femto, nano, micro, milli, centi, deci\n' 'Allowed prefixes are:\n'
'* deca, hecto, kilo, mega, giga, tera, peta, exa\n\n\n' '* atto, pico, femto, nano, micro, milli, centi, deci\n'
'Usage examples:\n' '* deca, hecto, kilo, mega, giga, tera, peta, exa\n\n\n'
'* `@convert 12 celsius fahrenheit`\n' 'Usage examples:\n'
'* `@convert 0.002 kilomile millimeter`\n' '* `@convert 12 celsius fahrenheit`\n'
'* `@convert 31.5 square-mile ha`\n' '* `@convert 0.002 kilomile millimeter`\n'
'* `@convert 56 g lb`\n') '* `@convert 31.5 square-mile ha`\n'
'* `@convert 56 g lb`\n'
)
QUICK_HELP = 'Enter `@convert help` for help on using the converter.' QUICK_HELP = 'Enter `@convert help` for help on using the converter.'

View file

@ -66,7 +66,9 @@ class DefineHandler:
# Show definitions line by line. # Show definitions line by line.
for d in definitions: for d in definitions:
example = d['example'] if d['example'] else '*No example available.*' example = d['example'] if d['example'] else '*No example available.*'
response += '\n' + '* (**{}**) {}\n&nbsp;&nbsp;{}'.format(d['type'], d['definition'], html2text.html2text(example)) response += '\n' + '* (**{}**) {}\n&nbsp;&nbsp;{}'.format(
d['type'], d['definition'], html2text.html2text(example)
)
except Exception: except Exception:
response += self.REQUEST_ERROR_MESSAGE response += self.REQUEST_ERROR_MESSAGE
@ -74,4 +76,5 @@ class DefineHandler:
return response return response
handler_class = DefineHandler handler_class = DefineHandler

View file

@ -9,25 +9,29 @@ class TestDefineBot(BotTestCase, DefaultTests):
def test_bot(self) -> None: def test_bot(self) -> None:
# Only one type(noun) of word. # Only one type(noun) of word.
bot_response = ("**cat**:\n\n* (**noun**) a small domesticated carnivorous mammal " bot_response = (
"with soft fur, a short snout, and retractile claws. It is widely " "**cat**:\n\n* (**noun**) a small domesticated carnivorous mammal "
"kept as a pet or for catching mice, and many breeds have been " "with soft fur, a short snout, and retractile claws. It is widely "
"developed.\n&nbsp;&nbsp;their pet cat\n\n") "kept as a pet or for catching mice, and many breeds have been "
"developed.\n&nbsp;&nbsp;their pet cat\n\n"
)
with self.mock_http_conversation('test_single_type_word'): with self.mock_http_conversation('test_single_type_word'):
self.verify_reply('cat', bot_response) self.verify_reply('cat', bot_response)
# Multi-type word. # Multi-type word.
bot_response = ("**help**:\n\n" bot_response = (
"* (**verb**) make it easier or possible for (someone) to do something by offering them one's services or resources.\n" "**help**:\n\n"
"&nbsp;&nbsp;they helped her with domestic chores\n\n\n" "* (**verb**) make it easier or possible for (someone) to do something by offering them one's services or resources.\n"
"* (**verb**) serve someone with (food or drink).\n" "&nbsp;&nbsp;they helped her with domestic chores\n\n\n"
"&nbsp;&nbsp;may I help you to some more meat?\n\n\n" "* (**verb**) serve someone with (food or drink).\n"
"* (**verb**) cannot or could not avoid.\n" "&nbsp;&nbsp;may I help you to some more meat?\n\n\n"
"&nbsp;&nbsp;he couldn't help laughing\n\n\n" "* (**verb**) cannot or could not avoid.\n"
"* (**noun**) the action of helping someone to do something.\n" "&nbsp;&nbsp;he couldn't help laughing\n\n\n"
"&nbsp;&nbsp;I asked for help from my neighbours\n\n\n" "* (**noun**) the action of helping someone to do something.\n"
"* (**exclamation**) used as an appeal for urgent assistance.\n" "&nbsp;&nbsp;I asked for help from my neighbours\n\n\n"
"&nbsp;&nbsp;Help! I'm drowning!\n\n") "* (**exclamation**) used as an appeal for urgent assistance.\n"
"&nbsp;&nbsp;Help! I'm drowning!\n\n"
)
with self.mock_http_conversation('test_multi_type_word'): with self.mock_http_conversation('test_multi_type_word'):
self.verify_reply('help', bot_response) self.verify_reply('help', bot_response)
@ -49,8 +53,5 @@ class TestDefineBot(BotTestCase, DefaultTests):
self.verify_reply('', bot_response) self.verify_reply('', bot_response)
def test_connection_error(self) -> None: def test_connection_error(self) -> None:
with patch('requests.get', side_effect=Exception), \ with patch('requests.get', side_effect=Exception), patch('logging.exception'):
patch('logging.exception'): self.verify_reply('aeroplane', '**aeroplane**:\nCould not load definition.')
self.verify_reply(
'aeroplane',
'**aeroplane**:\nCould not load definition.')

View file

@ -12,6 +12,7 @@ This bot will interact with dialogflow bots.
Simply send this bot a message, and it will respond depending on the configured bot's behaviour. Simply send this bot a message, and it will respond depending on the configured bot's behaviour.
''' '''
def get_bot_result(message_content: str, config: Dict[str, str], sender_id: str) -> str: def get_bot_result(message_content: str, config: Dict[str, str], sender_id: str) -> str:
if message_content.strip() == '' or message_content.strip() == 'help': if message_content.strip() == '' or message_content.strip() == 'help':
return config['bot_info'] return config['bot_info']
@ -24,7 +25,9 @@ def get_bot_result(message_content: str, config: Dict[str, str], sender_id: str)
res_str = response.read().decode('utf8', 'ignore') res_str = response.read().decode('utf8', 'ignore')
res_json = json.loads(res_str) res_json = json.loads(res_str)
if res_json['status']['errorType'] != 'success' and 'result' not in res_json.keys(): if res_json['status']['errorType'] != 'success' and 'result' not in res_json.keys():
return 'Error {}: {}.'.format(res_json['status']['code'], res_json['status']['errorDetails']) return 'Error {}: {}.'.format(
res_json['status']['code'], res_json['status']['errorDetails']
)
if res_json['result']['fulfillment']['speech'] == '': if res_json['result']['fulfillment']['speech'] == '':
if 'alternateResult' in res_json.keys(): if 'alternateResult' in res_json.keys():
if res_json['alternateResult']['fulfillment']['speech'] != '': if res_json['alternateResult']['fulfillment']['speech'] != '':
@ -35,6 +38,7 @@ def get_bot_result(message_content: str, config: Dict[str, str], sender_id: str)
logging.exception(str(e)) logging.exception(str(e))
return 'Error. {}.'.format(str(e)) return 'Error. {}.'.format(str(e))
class DialogFlowHandler: class DialogFlowHandler:
''' '''
This plugin allows users to easily add their own This plugin allows users to easily add their own
@ -54,4 +58,5 @@ class DialogFlowHandler:
result = get_bot_result(message['content'], self.config_info, message['sender_id']) result = get_bot_result(message['content'], self.config_info, message['sender_id'])
bot_handler.send_reply(message, result) bot_handler.send_reply(message, result)
handler_class = DialogFlowHandler handler_class = DialogFlowHandler

View file

@ -6,14 +6,15 @@ from unittest.mock import patch
from zulip_bots.test_lib import BotTestCase, DefaultTests, read_bot_fixture_data from zulip_bots.test_lib import BotTestCase, DefaultTests, read_bot_fixture_data
class MockHttplibRequest(): class MockHttplibRequest:
def __init__(self, response: str) -> None: def __init__(self, response: str) -> None:
self.response = response self.response = response
def read(self) -> ByteString: def read(self) -> ByteString:
return json.dumps(self.response).encode() return json.dumps(self.response).encode()
class MockTextRequest():
class MockTextRequest:
def __init__(self) -> None: def __init__(self) -> None:
self.session_id = "" self.session_id = ""
self.query = "" self.query = ""
@ -22,6 +23,7 @@ class MockTextRequest():
def getresponse(self) -> MockHttplibRequest: def getresponse(self) -> MockHttplibRequest:
return MockHttplibRequest(self.response) return MockHttplibRequest(self.response)
@contextmanager @contextmanager
def mock_dialogflow(test_name: str, bot_name: str) -> Iterator[None]: def mock_dialogflow(test_name: str, bot_name: str) -> Iterator[None]:
response_data = read_bot_fixture_data(bot_name, test_name) response_data = read_bot_fixture_data(bot_name, test_name)
@ -38,12 +40,14 @@ def mock_dialogflow(test_name: str, bot_name: str) -> Iterator[None]:
mock_text_request.return_value = request mock_text_request.return_value = request
yield yield
class TestDialogFlowBot(BotTestCase, DefaultTests): class TestDialogFlowBot(BotTestCase, DefaultTests):
bot_name = 'dialogflow' bot_name = 'dialogflow'
def _test(self, test_name: str, message: str, response: str) -> None: def _test(self, test_name: str, message: str, response: str) -> None:
with self.mock_config_info({'key': 'abcdefg', 'bot_info': 'bot info foo bar'}), \ with self.mock_config_info(
mock_dialogflow(test_name, 'dialogflow'): {'key': 'abcdefg', 'bot_info': 'bot info foo bar'}
), mock_dialogflow(test_name, 'dialogflow'):
self.verify_reply(message, response) self.verify_reply(message, response)
def test_normal(self) -> None: def test_normal(self) -> None:

View file

@ -7,6 +7,7 @@ from zulip_bots.lib import BotHandler
URL = "[{name}](https://www.dropbox.com/home{path})" URL = "[{name}](https://www.dropbox.com/home{path})"
class DropboxHandler: class DropboxHandler:
''' '''
This bot allows you to easily share, search and upload files This bot allows you to easily share, search and upload files
@ -28,6 +29,7 @@ class DropboxHandler:
msg = dbx_command(self.client, command) msg = dbx_command(self.client, command)
bot_handler.send_reply(message, msg) bot_handler.send_reply(message, msg)
def get_help() -> str: def get_help() -> str:
return ''' return '''
Example commands: Example commands:
@ -44,6 +46,7 @@ def get_help() -> str:
``` ```
''' '''
def get_usage_examples() -> str: def get_usage_examples() -> str:
return ''' return '''
Usage: Usage:
@ -61,15 +64,17 @@ def get_usage_examples() -> str:
``` ```
''' '''
REGEXES = dict( REGEXES = dict(
command='(ls|mkdir|read|rm|write|search|usage|help)', command='(ls|mkdir|read|rm|write|search|usage|help)',
path=r'(\S+)', path=r'(\S+)',
optional_path=r'(\S*)', optional_path=r'(\S*)',
some_text='(.+?)', some_text='(.+?)',
folder=r'?(?:--fd (\S+))?', folder=r'?(?:--fd (\S+))?',
max_results=r'?(?:--mr (\d+))?' max_results=r'?(?:--mr (\d+))?',
) )
def get_commands() -> Dict[str, Tuple[Any, List[str]]]: def get_commands() -> Dict[str, Tuple[Any, List[str]]]:
return { return {
'help': (dbx_help, ['command']), 'help': (dbx_help, ['command']),
@ -83,12 +88,13 @@ def get_commands() -> Dict[str, Tuple[Any, List[str]]]:
'usage': (dbx_usage, []), 'usage': (dbx_usage, []),
} }
def dbx_command(client: Any, cmd: str) -> str: def dbx_command(client: Any, cmd: str) -> str:
cmd = cmd.strip() cmd = cmd.strip()
if cmd == 'help': if cmd == 'help':
return get_help() return get_help()
cmd_name = cmd.split()[0] cmd_name = cmd.split()[0]
cmd_args = cmd[len(cmd_name):].strip() cmd_args = cmd[len(cmd_name) :].strip()
commands = get_commands() commands = get_commands()
if cmd_name not in commands: if cmd_name not in commands:
return 'ERROR: unrecognized command\n' + get_help() return 'ERROR: unrecognized command\n' + get_help()
@ -102,6 +108,7 @@ def dbx_command(client: Any, cmd: str) -> str:
else: else:
return 'ERROR: ' + syntax_help(cmd_name) return 'ERROR: ' + syntax_help(cmd_name)
def syntax_help(cmd_name: str) -> str: def syntax_help(cmd_name: str) -> str:
commands = get_commands() commands = get_commands()
f, arg_names = commands[cmd_name] f, arg_names = commands[cmd_name]
@ -112,23 +119,29 @@ def syntax_help(cmd_name: str) -> str:
cmd = cmd_name cmd = cmd_name
return 'syntax: {}'.format(cmd) return 'syntax: {}'.format(cmd)
def dbx_help(client: Any, cmd_name: str) -> str: def dbx_help(client: Any, cmd_name: str) -> str:
return syntax_help(cmd_name) return syntax_help(cmd_name)
def dbx_usage(client: Any) -> str: def dbx_usage(client: Any) -> str:
return get_usage_examples() return get_usage_examples()
def dbx_mkdir(client: Any, fn: str) -> str: def dbx_mkdir(client: Any, fn: str) -> str:
fn = '/' + fn # foo/boo -> /foo/boo fn = '/' + fn # foo/boo -> /foo/boo
try: try:
result = client.files_create_folder(fn) result = client.files_create_folder(fn)
msg = "CREATED FOLDER: " + URL.format(name=result.name, path=result.path_lower) msg = "CREATED FOLDER: " + URL.format(name=result.name, path=result.path_lower)
except Exception: except Exception:
msg = "Please provide a correct folder path and name.\n"\ msg = (
"Usage: `mkdir <foldername>` to create a folder." "Please provide a correct folder path and name.\n"
"Usage: `mkdir <foldername>` to create a folder."
)
return msg return msg
def dbx_ls(client: Any, fn: str) -> str: def dbx_ls(client: Any, fn: str) -> str:
if fn != '': if fn != '':
fn = '/' + fn fn = '/' + fn
@ -144,12 +157,15 @@ def dbx_ls(client: Any, fn: str) -> str:
msg = '`No files available`' msg = '`No files available`'
except Exception: except Exception:
msg = "Please provide a correct folder path\n"\ msg = (
"Usage: `ls <foldername>` to list folders in directory\n"\ "Please provide a correct folder path\n"
"or simply `ls` for listing folders in the root directory" "Usage: `ls <foldername>` to list folders in directory\n"
"or simply `ls` for listing folders in the root directory"
)
return msg return msg
def dbx_rm(client: Any, fn: str) -> str: def dbx_rm(client: Any, fn: str) -> str:
fn = '/' + fn fn = '/' + fn
@ -157,10 +173,13 @@ def dbx_rm(client: Any, fn: str) -> str:
result = client.files_delete(fn) result = client.files_delete(fn)
msg = "DELETED File/Folder : " + URL.format(name=result.name, path=result.path_lower) msg = "DELETED File/Folder : " + URL.format(name=result.name, path=result.path_lower)
except Exception: except Exception:
msg = "Please provide a correct folder path and name.\n"\ msg = (
"Usage: `rm <foldername>` to delete a folder in root directory." "Please provide a correct folder path and name.\n"
"Usage: `rm <foldername>` to delete a folder in root directory."
)
return msg return msg
def dbx_write(client: Any, fn: str, content: str) -> str: def dbx_write(client: Any, fn: str, content: str) -> str:
fn = '/' + fn fn = '/' + fn
@ -172,6 +191,7 @@ def dbx_write(client: Any, fn: str, content: str) -> str:
return msg return msg
def dbx_read(client: Any, fn: str) -> str: def dbx_read(client: Any, fn: str) -> str:
fn = '/' + fn fn = '/' + fn
@ -179,10 +199,13 @@ def dbx_read(client: Any, fn: str) -> str:
result = client.files_download(fn) result = client.files_download(fn)
msg = "**{}** :\n{}".format(result[0].name, result[1].text) msg = "**{}** :\n{}".format(result[0].name, result[1].text)
except Exception: except Exception:
msg = "Please provide a correct file path\nUsage: `read <filename>` to read content of a file" msg = (
"Please provide a correct file path\nUsage: `read <filename>` to read content of a file"
)
return msg return msg
def dbx_search(client: Any, query: str, folder: str, max_results: str) -> str: def dbx_search(client: Any, query: str, folder: str, max_results: str) -> str:
if folder is None: if folder is None:
folder = '' folder = ''
@ -201,17 +224,22 @@ def dbx_search(client: Any, query: str, folder: str, max_results: str) -> str:
msg = '\n'.join(msg_list) msg = '\n'.join(msg_list)
except Exception: except Exception:
msg = "Usage: `search <foldername> query --mr 10 --fd <folderName>`\n"\ msg = (
"Note:`--mr <int>` is optional and is used to specify maximun results.\n"\ "Usage: `search <foldername> query --mr 10 --fd <folderName>`\n"
" `--fd <folderName>` to search in specific folder." "Note:`--mr <int>` is optional and is used to specify maximun results.\n"
" `--fd <folderName>` to search in specific folder."
)
if msg == '': if msg == '':
msg = "No files/folders found matching your query.\n"\ msg = (
"For file name searching, the last token is used for prefix matching"\ "No files/folders found matching your query.\n"
" (i.e. “bat c” matches “bat cave” but not “batman car”)." "For file name searching, the last token is used for prefix matching"
" (i.e. “bat c” matches “bat cave” but not “batman car”)."
)
return msg return msg
def dbx_share(client: Any, fn: str): def dbx_share(client: Any, fn: str):
fn = '/' + fn fn = '/' + fn
try: try:
@ -222,4 +250,5 @@ def dbx_share(client: Any, fn: str):
return msg return msg
handler_class = DropboxHandler handler_class = DropboxHandler

View file

@ -13,50 +13,49 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests
def get_root_files_list(*args, **kwargs): def get_root_files_list(*args, **kwargs):
return MockListFolderResult( return MockListFolderResult(
entries = [ entries=[MockFileMetadata('foo', '/foo'), MockFileMetadata('boo', '/boo')], has_more=False
MockFileMetadata('foo', '/foo'),
MockFileMetadata('boo', '/boo')
],
has_more = False
) )
def get_folder_files_list(*args, **kwargs): def get_folder_files_list(*args, **kwargs):
return MockListFolderResult( return MockListFolderResult(
entries = [ entries=[
MockFileMetadata('moo', '/foo/moo'), MockFileMetadata('moo', '/foo/moo'),
MockFileMetadata('noo', '/foo/noo'), MockFileMetadata('noo', '/foo/noo'),
], ],
has_more = False has_more=False,
) )
def get_empty_files_list(*args, **kwargs): def get_empty_files_list(*args, **kwargs):
return MockListFolderResult( return MockListFolderResult(entries=[], has_more=False)
entries = [],
has_more = False
)
def create_file(*args, **kwargs): def create_file(*args, **kwargs):
return MockFileMetadata('foo', '/foo') return MockFileMetadata('foo', '/foo')
def download_file(*args, **kwargs): def download_file(*args, **kwargs):
return [MockFileMetadata('foo', '/foo'), MockHttpResponse('boo')] return [MockFileMetadata('foo', '/foo'), MockHttpResponse('boo')]
def search_files(*args, **kwargs): def search_files(*args, **kwargs):
return MockSearchResult([ return MockSearchResult(
MockSearchMatch( [
MockFileMetadata('foo', '/foo') MockSearchMatch(MockFileMetadata('foo', '/foo')),
), MockSearchMatch(MockFileMetadata('fooboo', '/fooboo')),
MockSearchMatch( ]
MockFileMetadata('fooboo', '/fooboo') )
)
])
def get_empty_search_result(*args, **kwargs): def get_empty_search_result(*args, **kwargs):
return MockSearchResult([]) return MockSearchResult([])
def get_shared_link(*args, **kwargs): def get_shared_link(*args, **kwargs):
return MockPathLinkMetadata('http://www.foo.com/boo') return MockPathLinkMetadata('http://www.foo.com/boo')
def get_help() -> str: def get_help() -> str:
return ''' return '''
Example commands: Example commands:
@ -73,6 +72,7 @@ def get_help() -> str:
``` ```
''' '''
class TestDropboxBot(BotTestCase, DefaultTests): class TestDropboxBot(BotTestCase, DefaultTests):
bot_name = "dropbox_share" bot_name = "dropbox_share"
config_info = {"access_token": "1234567890"} config_info = {"access_token": "1234567890"}
@ -83,116 +83,151 @@ class TestDropboxBot(BotTestCase, DefaultTests):
self.verify_reply('help', get_help()) self.verify_reply('help', get_help())
def test_dbx_ls_root(self): def test_dbx_ls_root(self):
bot_response = " - [foo](https://www.dropbox.com/home/foo)\n"\ bot_response = (
" - [boo](https://www.dropbox.com/home/boo)" " - [foo](https://www.dropbox.com/home/foo)\n"
with patch('dropbox.Dropbox.files_list_folder', side_effect=get_root_files_list), \ " - [boo](https://www.dropbox.com/home/boo)"
self.mock_config_info(self.config_info): )
with patch(
'dropbox.Dropbox.files_list_folder', side_effect=get_root_files_list
), self.mock_config_info(self.config_info):
self.verify_reply("ls", bot_response) self.verify_reply("ls", bot_response)
def test_dbx_ls_folder(self): def test_dbx_ls_folder(self):
bot_response = " - [moo](https://www.dropbox.com/home/foo/moo)\n"\ bot_response = (
" - [noo](https://www.dropbox.com/home/foo/noo)" " - [moo](https://www.dropbox.com/home/foo/moo)\n"
with patch('dropbox.Dropbox.files_list_folder', side_effect=get_folder_files_list), \ " - [noo](https://www.dropbox.com/home/foo/noo)"
self.mock_config_info(self.config_info): )
with patch(
'dropbox.Dropbox.files_list_folder', side_effect=get_folder_files_list
), self.mock_config_info(self.config_info):
self.verify_reply("ls foo", bot_response) self.verify_reply("ls foo", bot_response)
def test_dbx_ls_empty(self): def test_dbx_ls_empty(self):
bot_response = '`No files available`' bot_response = '`No files available`'
with patch('dropbox.Dropbox.files_list_folder', side_effect=get_empty_files_list), \ with patch(
self.mock_config_info(self.config_info): 'dropbox.Dropbox.files_list_folder', side_effect=get_empty_files_list
), self.mock_config_info(self.config_info):
self.verify_reply("ls", bot_response) self.verify_reply("ls", bot_response)
def test_dbx_ls_error(self): def test_dbx_ls_error(self):
bot_response = "Please provide a correct folder path\n"\ bot_response = (
"Usage: `ls <foldername>` to list folders in directory\n"\ "Please provide a correct folder path\n"
"or simply `ls` for listing folders in the root directory" "Usage: `ls <foldername>` to list folders in directory\n"
with patch('dropbox.Dropbox.files_list_folder', side_effect=Exception()), \ "or simply `ls` for listing folders in the root directory"
self.mock_config_info(self.config_info): )
with patch(
'dropbox.Dropbox.files_list_folder', side_effect=Exception()
), self.mock_config_info(self.config_info):
self.verify_reply("ls", bot_response) self.verify_reply("ls", bot_response)
def test_dbx_mkdir(self): def test_dbx_mkdir(self):
bot_response = "CREATED FOLDER: [foo](https://www.dropbox.com/home/foo)" bot_response = "CREATED FOLDER: [foo](https://www.dropbox.com/home/foo)"
with patch('dropbox.Dropbox.files_create_folder', side_effect=create_file), \ with patch(
self.mock_config_info(self.config_info): 'dropbox.Dropbox.files_create_folder', side_effect=create_file
), self.mock_config_info(self.config_info):
self.verify_reply('mkdir foo', bot_response) self.verify_reply('mkdir foo', bot_response)
def test_dbx_mkdir_error(self): def test_dbx_mkdir_error(self):
bot_response = "Please provide a correct folder path and name.\n"\ bot_response = (
"Usage: `mkdir <foldername>` to create a folder." "Please provide a correct folder path and name.\n"
with patch('dropbox.Dropbox.files_create_folder', side_effect=Exception()), \ "Usage: `mkdir <foldername>` to create a folder."
self.mock_config_info(self.config_info): )
with patch(
'dropbox.Dropbox.files_create_folder', side_effect=Exception()
), self.mock_config_info(self.config_info):
self.verify_reply('mkdir foo/bar', bot_response) self.verify_reply('mkdir foo/bar', bot_response)
def test_dbx_rm(self): def test_dbx_rm(self):
bot_response = "DELETED File/Folder : [foo](https://www.dropbox.com/home/foo)" bot_response = "DELETED File/Folder : [foo](https://www.dropbox.com/home/foo)"
with patch('dropbox.Dropbox.files_delete', side_effect=create_file), \ with patch('dropbox.Dropbox.files_delete', side_effect=create_file), self.mock_config_info(
self.mock_config_info(self.config_info): self.config_info
):
self.verify_reply('rm foo', bot_response) self.verify_reply('rm foo', bot_response)
def test_dbx_rm_error(self): def test_dbx_rm_error(self):
bot_response = "Please provide a correct folder path and name.\n"\ bot_response = (
"Usage: `rm <foldername>` to delete a folder in root directory." "Please provide a correct folder path and name.\n"
with patch('dropbox.Dropbox.files_delete', side_effect=Exception()), \ "Usage: `rm <foldername>` to delete a folder in root directory."
self.mock_config_info(self.config_info): )
with patch('dropbox.Dropbox.files_delete', side_effect=Exception()), self.mock_config_info(
self.config_info
):
self.verify_reply('rm foo', bot_response) self.verify_reply('rm foo', bot_response)
def test_dbx_write(self): def test_dbx_write(self):
bot_response = "Written to file: [foo](https://www.dropbox.com/home/foo)" bot_response = "Written to file: [foo](https://www.dropbox.com/home/foo)"
with patch('dropbox.Dropbox.files_upload', side_effect=create_file), \ with patch('dropbox.Dropbox.files_upload', side_effect=create_file), self.mock_config_info(
self.mock_config_info(self.config_info): self.config_info
):
self.verify_reply('write foo boo', bot_response) self.verify_reply('write foo boo', bot_response)
def test_dbx_write_error(self): def test_dbx_write_error(self):
bot_response = "Incorrect file path or file already exists.\nUsage: `write <filename> CONTENT`" bot_response = (
with patch('dropbox.Dropbox.files_upload', side_effect=Exception()), \ "Incorrect file path or file already exists.\nUsage: `write <filename> CONTENT`"
self.mock_config_info(self.config_info): )
with patch('dropbox.Dropbox.files_upload', side_effect=Exception()), self.mock_config_info(
self.config_info
):
self.verify_reply('write foo boo', bot_response) self.verify_reply('write foo boo', bot_response)
def test_dbx_read(self): def test_dbx_read(self):
bot_response = "**foo** :\nboo" bot_response = "**foo** :\nboo"
with patch('dropbox.Dropbox.files_download', side_effect=download_file), \ with patch(
self.mock_config_info(self.config_info): 'dropbox.Dropbox.files_download', side_effect=download_file
), self.mock_config_info(self.config_info):
self.verify_reply('read foo', bot_response) self.verify_reply('read foo', bot_response)
def test_dbx_read_error(self): def test_dbx_read_error(self):
bot_response = "Please provide a correct file path\n"\ bot_response = (
"Usage: `read <filename>` to read content of a file" "Please provide a correct file path\n"
with patch('dropbox.Dropbox.files_download', side_effect=Exception()), \ "Usage: `read <filename>` to read content of a file"
self.mock_config_info(self.config_info): )
with patch(
'dropbox.Dropbox.files_download', side_effect=Exception()
), self.mock_config_info(self.config_info):
self.verify_reply('read foo', bot_response) self.verify_reply('read foo', bot_response)
def test_dbx_search(self): def test_dbx_search(self):
bot_response = " - [foo](https://www.dropbox.com/home/foo)\n - [fooboo](https://www.dropbox.com/home/fooboo)" bot_response = " - [foo](https://www.dropbox.com/home/foo)\n - [fooboo](https://www.dropbox.com/home/fooboo)"
with patch('dropbox.Dropbox.files_search', side_effect=search_files), \ with patch('dropbox.Dropbox.files_search', side_effect=search_files), self.mock_config_info(
self.mock_config_info(self.config_info): self.config_info
):
self.verify_reply('search foo', bot_response) self.verify_reply('search foo', bot_response)
def test_dbx_search_empty(self): def test_dbx_search_empty(self):
bot_response = "No files/folders found matching your query.\n"\ bot_response = (
"For file name searching, the last token is used for prefix matching"\ "No files/folders found matching your query.\n"
" (i.e. “bat c” matches “bat cave” but not “batman car”)." "For file name searching, the last token is used for prefix matching"
with patch('dropbox.Dropbox.files_search', side_effect=get_empty_search_result), \ " (i.e. “bat c” matches “bat cave” but not “batman car”)."
self.mock_config_info(self.config_info): )
with patch(
'dropbox.Dropbox.files_search', side_effect=get_empty_search_result
), self.mock_config_info(self.config_info):
self.verify_reply('search boo --fd foo', bot_response) self.verify_reply('search boo --fd foo', bot_response)
def test_dbx_search_error(self): def test_dbx_search_error(self):
bot_response = "Usage: `search <foldername> query --mr 10 --fd <folderName>`\n"\ bot_response = (
"Note:`--mr <int>` is optional and is used to specify maximun results.\n"\ "Usage: `search <foldername> query --mr 10 --fd <folderName>`\n"
" `--fd <folderName>` to search in specific folder." "Note:`--mr <int>` is optional and is used to specify maximun results.\n"
with patch('dropbox.Dropbox.files_search', side_effect=Exception()), \ " `--fd <folderName>` to search in specific folder."
self.mock_config_info(self.config_info): )
with patch('dropbox.Dropbox.files_search', side_effect=Exception()), self.mock_config_info(
self.config_info
):
self.verify_reply('search foo', bot_response) self.verify_reply('search foo', bot_response)
def test_dbx_share(self): def test_dbx_share(self):
bot_response = 'http://www.foo.com/boo' bot_response = 'http://www.foo.com/boo'
with patch('dropbox.Dropbox.sharing_create_shared_link', side_effect=get_shared_link), \ with patch(
self.mock_config_info(self.config_info): 'dropbox.Dropbox.sharing_create_shared_link', side_effect=get_shared_link
), self.mock_config_info(self.config_info):
self.verify_reply('share boo', bot_response) self.verify_reply('share boo', bot_response)
def test_dbx_share_error(self): def test_dbx_share_error(self):
bot_response = "Please provide a correct file name.\nUsage: `share <filename>`" bot_response = "Please provide a correct file name.\nUsage: `share <filename>`"
with patch('dropbox.Dropbox.sharing_create_shared_link', side_effect=Exception()), \ with patch(
self.mock_config_info(self.config_info): 'dropbox.Dropbox.sharing_create_shared_link', side_effect=Exception()
), self.mock_config_info(self.config_info):
self.verify_reply('share boo', bot_response) self.verify_reply('share boo', bot_response)
def test_dbx_help(self): def test_dbx_help(self):

View file

@ -6,23 +6,28 @@ class MockFileMetadata:
self.name = name self.name = name
self.path_lower = path_lower self.path_lower = path_lower
class MockListFolderResult: class MockListFolderResult:
def __init__(self, entries: str, has_more: str): def __init__(self, entries: str, has_more: str):
self.entries = entries self.entries = entries
self.has_more = has_more self.has_more = has_more
class MockSearchMatch: class MockSearchMatch:
def __init__(self, metadata: List[MockFileMetadata]): def __init__(self, metadata: List[MockFileMetadata]):
self.metadata = metadata self.metadata = metadata
class MockSearchResult: class MockSearchResult:
def __init__(self, matches: List[MockSearchMatch]): def __init__(self, matches: List[MockSearchMatch]):
self.matches = matches self.matches = matches
class MockPathLinkMetadata: class MockPathLinkMetadata:
def __init__(self, url: str): def __init__(self, url: str):
self.url = url self.url = url
class MockHttpResponse: class MockHttpResponse:
def __init__(self, text: str): def __init__(self, text: str):
self.text = text self.text = text

View file

@ -20,6 +20,7 @@ def encrypt(text: str) -> str:
return newtext return newtext
class EncryptHandler: class EncryptHandler:
''' '''
This bot allows users to quickly encrypt messages using ROT13 encryption. This bot allows users to quickly encrypt messages using ROT13 encryption.
@ -43,4 +44,5 @@ class EncryptHandler:
send_content = "Encrypted/Decrypted text: " + temp_content send_content = "Encrypted/Decrypted text: " + temp_content
return send_content return send_content
handler_class = EncryptHandler handler_class = EncryptHandler

View file

@ -40,4 +40,5 @@ class FileUploaderHandler:
uploaded_file_reply = '[{}]({})'.format(path.name, upload['uri']) uploaded_file_reply = '[{}]({})'.format(path.name, upload['uri'])
bot_handler.send_reply(message, uploaded_file_reply) bot_handler.send_reply(message, uploaded_file_reply)
handler_class = FileUploaderHandler handler_class = FileUploaderHandler

View file

@ -15,20 +15,28 @@ class TestFileUploaderBot(BotTestCase, DefaultTests):
@patch('pathlib.Path.is_file', return_value=True) @patch('pathlib.Path.is_file', return_value=True)
def test_file_upload_failed(self, is_file: Mock, resolve: Mock) -> None: def test_file_upload_failed(self, is_file: Mock, resolve: Mock) -> None:
server_reply = dict(result='', msg='error') server_reply = dict(result='', msg='error')
with patch('zulip_bots.test_lib.StubBotHandler.upload_file_from_path', with patch(
return_value=server_reply): 'zulip_bots.test_lib.StubBotHandler.upload_file_from_path', return_value=server_reply
self.verify_reply('file.txt', 'Failed to upload `{}` file: error'.format(Path('file.txt').resolve())) ):
self.verify_reply(
'file.txt', 'Failed to upload `{}` file: error'.format(Path('file.txt').resolve())
)
@patch('pathlib.Path.resolve', return_value=Path('/file.txt')) @patch('pathlib.Path.resolve', return_value=Path('/file.txt'))
@patch('pathlib.Path.is_file', return_value=True) @patch('pathlib.Path.is_file', return_value=True)
def test_file_upload_success(self, is_file: Mock, resolve: Mock) -> None: def test_file_upload_success(self, is_file: Mock, resolve: Mock) -> None:
server_reply = dict(result='success', uri='https://file/uri') server_reply = dict(result='success', uri='https://file/uri')
with patch('zulip_bots.test_lib.StubBotHandler.upload_file_from_path', with patch(
return_value=server_reply): 'zulip_bots.test_lib.StubBotHandler.upload_file_from_path', return_value=server_reply
):
self.verify_reply('file.txt', '[file.txt](https://file/uri)') self.verify_reply('file.txt', '[file.txt](https://file/uri)')
def test_help(self): def test_help(self):
self.verify_reply('help', self.verify_reply(
('Use this bot with any of the following commands:' 'help',
'\n* `@uploader <local_file_path>` : Upload a file, where `<local_file_path>` is the path to the file' (
'\n* `@uploader help` : Display help message')) 'Use this bot with any of the following commands:'
'\n* `@uploader <local_file_path>` : Upload a file, where `<local_file_path>` is the path to the file'
'\n* `@uploader help` : Display help message'
),
)

View file

@ -21,6 +21,7 @@ def find_recipient_id(users: List[Any], recipient_name: str) -> str:
if recipient_name == user['firstName']: if recipient_name == user['firstName']:
return user['id'] return user['id']
# Make request to given flock URL and return a two-element tuple # Make request to given flock URL and return a two-element tuple
# whose left-hand value contains JSON body of response (or None if request failed) # whose left-hand value contains JSON body of response (or None if request failed)
# and whose right-hand value contains an error message (or None if request succeeded) # and whose right-hand value contains an error message (or None if request succeeded)
@ -34,14 +35,15 @@ def make_flock_request(url: str, params: Dict[str, str]) -> Tuple[Any, str]:
right now.\nPlease try again later" right now.\nPlease try again later"
return (None, error) return (None, error)
# Returns two-element tuple whose left-hand value contains recipient # Returns two-element tuple whose left-hand value contains recipient
# user's ID (or None if it was not found) and right-hand value contains # user's ID (or None if it was not found) and right-hand value contains
# an error message (or None if recipient user's ID was found) # an error message (or None if recipient user's ID was found)
def get_recipient_id(recipient_name: str, config: Dict[str, str]) -> Tuple[Optional[str], Optional[str]]: def get_recipient_id(
recipient_name: str, config: Dict[str, str]
) -> Tuple[Optional[str], Optional[str]]:
token = config['token'] token = config['token']
payload = { payload = {'token': token}
'token': token
}
users, error = make_flock_request(USERS_LIST_URL, payload) users, error = make_flock_request(USERS_LIST_URL, payload)
if users is None: if users is None:
return (None, error) return (None, error)
@ -53,6 +55,7 @@ def get_recipient_id(recipient_name: str, config: Dict[str, str]) -> Tuple[Optio
else: else:
return (recipient_id, None) return (recipient_id, None)
# This handles the message sending work. # This handles the message sending work.
def get_flock_response(content: str, config: Dict[str, str]) -> str: def get_flock_response(content: str, config: Dict[str, str]) -> str:
token = config['token'] token = config['token']
@ -67,11 +70,7 @@ def get_flock_response(content: str, config: Dict[str, str]) -> str:
if len(str(recipient_id)) > 30: if len(str(recipient_id)) > 30:
return "Found user is invalid." return "Found user is invalid."
payload = { payload = {'to': recipient_id, 'text': message, 'token': token}
'to': recipient_id,
'text': message,
'token': token
}
res, error = make_flock_request(SEND_MESSAGE_URL, payload) res, error = make_flock_request(SEND_MESSAGE_URL, payload)
if res is None: if res is None:
return error return error
@ -81,6 +80,7 @@ def get_flock_response(content: str, config: Dict[str, str]) -> str:
else: else:
return "Message sending failed :slightly_frowning_face:. Please try again." return "Message sending failed :slightly_frowning_face:. Please try again."
def get_flock_bot_response(content: str, config: Dict[str, str]) -> None: def get_flock_bot_response(content: str, config: Dict[str, str]) -> None:
content = content.strip() content = content.strip()
if content == '' or content == 'help': if content == '' or content == 'help':
@ -89,6 +89,7 @@ def get_flock_bot_response(content: str, config: Dict[str, str]) -> None:
result = get_flock_response(content, config) result = get_flock_response(content, config)
return result return result
class FlockHandler: class FlockHandler:
''' '''
This is flock bot. Now you can send messages to any of your This is flock bot. Now you can send messages to any of your
@ -106,4 +107,5 @@ right from Zulip.'''
response = get_flock_bot_response(message['content'], self.config_info) response = get_flock_bot_response(message['content'], self.config_info)
bot_handler.send_reply(message, response) bot_handler.send_reply(message, response)
handler_class = FlockHandler handler_class = FlockHandler

View file

@ -9,11 +9,7 @@ class TestFlockBot(BotTestCase, DefaultTests):
bot_name = "flock" bot_name = "flock"
normal_config = {"token": "12345"} normal_config = {"token": "12345"}
message_config = { message_config = {"token": "12345", "text": "Ricky: test message", "to": "u:somekey"}
"token": "12345",
"text": "Ricky: test message",
"to": "u:somekey"
}
help_message = ''' help_message = '''
You can send messages to any Flock user associated with your account from Zulip. You can send messages to any Flock user associated with your account from Zulip.
@ -27,52 +23,62 @@ You can send messages to any Flock user associated with your account from Zulip.
self.verify_reply('', self.help_message) self.verify_reply('', self.help_message)
def test_fetch_id_connection_error(self) -> None: def test_fetch_id_connection_error(self) -> None:
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), patch(
patch('requests.get', side_effect=ConnectionError()), \ 'requests.get', side_effect=ConnectionError()
patch('logging.exception'): ), patch('logging.exception'):
self.verify_reply('tyler: Hey tyler', "Uh-Oh, couldn\'t process the request \ self.verify_reply(
right now.\nPlease try again later") 'tyler: Hey tyler',
"Uh-Oh, couldn\'t process the request \
right now.\nPlease try again later",
)
def test_response_connection_error(self) -> None: def test_response_connection_error(self) -> None:
with self.mock_config_info(self.message_config), \ with self.mock_config_info(self.message_config), patch(
patch('requests.get', side_effect=ConnectionError()), \ 'requests.get', side_effect=ConnectionError()
patch('logging.exception'): ), patch('logging.exception'):
self.verify_reply('Ricky: test message', "Uh-Oh, couldn\'t process the request \ self.verify_reply(
right now.\nPlease try again later") 'Ricky: test message',
"Uh-Oh, couldn\'t process the request \
right now.\nPlease try again later",
)
def test_no_recipient_found(self) -> None: def test_no_recipient_found(self) -> None:
bot_response = "No user found. Make sure you typed it correctly." bot_response = "No user found. Make sure you typed it correctly."
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), self.mock_http_conversation(
self.mock_http_conversation('test_no_recipient_found'): 'test_no_recipient_found'
):
self.verify_reply('david: hello', bot_response) self.verify_reply('david: hello', bot_response)
def test_found_invalid_recipient(self) -> None: def test_found_invalid_recipient(self) -> None:
bot_response = "Found user is invalid." bot_response = "Found user is invalid."
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), self.mock_http_conversation(
self.mock_http_conversation('test_found_invalid_recipient'): 'test_found_invalid_recipient'
):
self.verify_reply('david: hello', bot_response) self.verify_reply('david: hello', bot_response)
@patch('zulip_bots.bots.flock.flock.get_recipient_id') @patch('zulip_bots.bots.flock.flock.get_recipient_id')
def test_message_send_connection_error(self, get_recipient_id: str) -> None: def test_message_send_connection_error(self, get_recipient_id: str) -> None:
bot_response = "Uh-Oh, couldn't process the request right now.\nPlease try again later" bot_response = "Uh-Oh, couldn't process the request right now.\nPlease try again later"
get_recipient_id.return_value = ["u:userid", None] get_recipient_id.return_value = ["u:userid", None]
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), patch(
patch('requests.get', side_effect=ConnectionError()), \ 'requests.get', side_effect=ConnectionError()
patch('logging.exception'): ), patch('logging.exception'):
self.verify_reply('Rishabh: hi there', bot_response) self.verify_reply('Rishabh: hi there', bot_response)
@patch('zulip_bots.bots.flock.flock.get_recipient_id') @patch('zulip_bots.bots.flock.flock.get_recipient_id')
def test_message_send_success(self, get_recipient_id: str) -> None: def test_message_send_success(self, get_recipient_id: str) -> None:
bot_response = "Message sent." bot_response = "Message sent."
get_recipient_id.return_value = ["u:userid", None] get_recipient_id.return_value = ["u:userid", None]
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), self.mock_http_conversation(
self.mock_http_conversation('test_message_send_success'): 'test_message_send_success'
):
self.verify_reply('Rishabh: hi there', bot_response) self.verify_reply('Rishabh: hi there', bot_response)
@patch('zulip_bots.bots.flock.flock.get_recipient_id') @patch('zulip_bots.bots.flock.flock.get_recipient_id')
def test_message_send_failed(self, get_recipient_id: str) -> None: def test_message_send_failed(self, get_recipient_id: str) -> None:
bot_response = "Message sending failed :slightly_frowning_face:. Please try again." bot_response = "Message sending failed :slightly_frowning_face:. Please try again."
get_recipient_id.return_value = ["u:invalid", None] get_recipient_id.return_value = ["u:invalid", None]
with self.mock_config_info(self.normal_config), \ with self.mock_config_info(self.normal_config), self.mock_http_conversation(
self.mock_http_conversation('test_message_send_failed'): 'test_message_send_failed'
):
self.verify_reply('Rishabh: hi there', bot_response) self.verify_reply('Rishabh: hi there', bot_response)

View file

@ -32,18 +32,22 @@ class FollowupHandler:
def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None:
if message['content'] == '': if message['content'] == '':
bot_response = "Please specify the message you want to send to followup stream after @mention-bot" bot_response = (
"Please specify the message you want to send to followup stream after @mention-bot"
)
bot_handler.send_reply(message, bot_response) bot_handler.send_reply(message, bot_response)
elif message['content'] == 'help': elif message['content'] == 'help':
bot_handler.send_reply(message, self.usage()) bot_handler.send_reply(message, self.usage())
else: else:
bot_response = self.get_bot_followup_response(message) bot_response = self.get_bot_followup_response(message)
bot_handler.send_message(dict( bot_handler.send_message(
type='stream', dict(
to=self.stream, type='stream',
subject=message['sender_email'], to=self.stream,
content=bot_response, subject=message['sender_email'],
)) content=bot_response,
)
)
def get_bot_followup_response(self, message: Dict[str, str]) -> str: def get_bot_followup_response(self, message: Dict[str, str]) -> str:
original_content = message['content'] original_content = message['content']
@ -53,4 +57,5 @@ class FollowupHandler:
return new_content return new_content
handler_class = FollowupHandler handler_class = FollowupHandler

View file

@ -31,7 +31,9 @@ class TestFollowUpBot(BotTestCase, DefaultTests):
self.assertEqual(response['to'], 'issue') self.assertEqual(response['to'], 'issue')
def test_bot_responds_to_empty_message(self) -> None: def test_bot_responds_to_empty_message(self) -> None:
bot_response = 'Please specify the message you want to send to followup stream after @mention-bot' bot_response = (
'Please specify the message you want to send to followup stream after @mention-bot'
)
with self.mock_config_info({'stream': 'followup'}): with self.mock_config_info({'stream': 'followup'}):
self.verify_reply('', bot_response) self.verify_reply('', bot_response)

View file

@ -13,7 +13,7 @@ class FrontHandler:
('delete', "Delete a conversation."), ('delete', "Delete a conversation."),
('spam', "Mark a conversation as spam."), ('spam', "Mark a conversation as spam."),
('open', "Restore a conversation."), ('open', "Restore a conversation."),
('comment <text>', "Leave a comment.") ('comment <text>', "Leave a comment."),
] ]
CNV_ID_REGEXP = 'cnv_(?P<id>[0-9a-z]+)' CNV_ID_REGEXP = 'cnv_(?P<id>[0-9a-z]+)'
COMMENT_PREFIX = "comment " COMMENT_PREFIX = "comment "
@ -40,9 +40,11 @@ class FrontHandler:
return response return response
def archive(self, bot_handler: BotHandler) -> str: def archive(self, bot_handler: BotHandler) -> str:
response = requests.patch(self.FRONT_API.format(self.conversation_id), response = requests.patch(
headers={"Authorization": self.auth}, self.FRONT_API.format(self.conversation_id),
json={"status": "archived"}) headers={"Authorization": self.auth},
json={"status": "archived"},
)
if response.status_code not in (200, 204): if response.status_code not in (200, 204):
return "Something went wrong." return "Something went wrong."
@ -50,9 +52,11 @@ class FrontHandler:
return "Conversation was archived." return "Conversation was archived."
def delete(self, bot_handler: BotHandler) -> str: def delete(self, bot_handler: BotHandler) -> str:
response = requests.patch(self.FRONT_API.format(self.conversation_id), response = requests.patch(
headers={"Authorization": self.auth}, self.FRONT_API.format(self.conversation_id),
json={"status": "deleted"}) headers={"Authorization": self.auth},
json={"status": "deleted"},
)
if response.status_code not in (200, 204): if response.status_code not in (200, 204):
return "Something went wrong." return "Something went wrong."
@ -60,9 +64,11 @@ class FrontHandler:
return "Conversation was deleted." return "Conversation was deleted."
def spam(self, bot_handler: BotHandler) -> str: def spam(self, bot_handler: BotHandler) -> str:
response = requests.patch(self.FRONT_API.format(self.conversation_id), response = requests.patch(
headers={"Authorization": self.auth}, self.FRONT_API.format(self.conversation_id),
json={"status": "spam"}) headers={"Authorization": self.auth},
json={"status": "spam"},
)
if response.status_code not in (200, 204): if response.status_code not in (200, 204):
return "Something went wrong." return "Something went wrong."
@ -70,9 +76,11 @@ class FrontHandler:
return "Conversation was marked as spam." return "Conversation was marked as spam."
def restore(self, bot_handler: BotHandler) -> str: def restore(self, bot_handler: BotHandler) -> str:
response = requests.patch(self.FRONT_API.format(self.conversation_id), response = requests.patch(
headers={"Authorization": self.auth}, self.FRONT_API.format(self.conversation_id),
json={"status": "open"}) headers={"Authorization": self.auth},
json={"status": "open"},
)
if response.status_code not in (200, 204): if response.status_code not in (200, 204):
return "Something went wrong." return "Something went wrong."
@ -80,8 +88,11 @@ class FrontHandler:
return "Conversation was restored." return "Conversation was restored."
def comment(self, bot_handler: BotHandler, **kwargs: Any) -> str: def comment(self, bot_handler: BotHandler, **kwargs: Any) -> str:
response = requests.post(self.FRONT_API.format(self.conversation_id) + "/comments", response = requests.post(
headers={"Authorization": self.auth}, json=kwargs) self.FRONT_API.format(self.conversation_id) + "/comments",
headers={"Authorization": self.auth},
json=kwargs,
)
if response.status_code not in (200, 201): if response.status_code not in (200, 201):
return "Something went wrong." return "Something went wrong."
@ -93,9 +104,12 @@ class FrontHandler:
result = re.search(self.CNV_ID_REGEXP, message['subject']) result = re.search(self.CNV_ID_REGEXP, message['subject'])
if not result: if not result:
bot_handler.send_reply(message, "No coversation ID found. Please make " bot_handler.send_reply(
"sure that the name of the topic " message,
"contains a valid coversation ID.") "No coversation ID found. Please make "
"sure that the name of the topic "
"contains a valid coversation ID.",
)
return None return None
self.conversation_id = result.group() self.conversation_id = result.group()
@ -118,10 +132,11 @@ class FrontHandler:
elif command.startswith(self.COMMENT_PREFIX): elif command.startswith(self.COMMENT_PREFIX):
kwargs = { kwargs = {
'author_id': "alt:email:" + message['sender_email'], 'author_id': "alt:email:" + message['sender_email'],
'body': command[len(self.COMMENT_PREFIX):] 'body': command[len(self.COMMENT_PREFIX) :],
} }
bot_handler.send_reply(message, self.comment(bot_handler, **kwargs)) bot_handler.send_reply(message, self.comment(bot_handler, **kwargs))
else: else:
bot_handler.send_reply(message, "Unknown command. Use `help` for instructions.") bot_handler.send_reply(message, "Unknown command. Use `help` for instructions.")
handler_class = FrontHandler handler_class = FrontHandler

View file

@ -24,11 +24,14 @@ class TestFrontBot(BotTestCase, DefaultTests):
def test_help(self) -> None: def test_help(self) -> None:
with self.mock_config_info({'api_key': "TEST"}): with self.mock_config_info({'api_key': "TEST"}):
self.verify_reply('help', "`archive` Archive a conversation.\n" self.verify_reply(
"`delete` Delete a conversation.\n" 'help',
"`spam` Mark a conversation as spam.\n" "`archive` Archive a conversation.\n"
"`open` Restore a conversation.\n" "`delete` Delete a conversation.\n"
"`comment <text>` Leave a comment.\n") "`spam` Mark a conversation as spam.\n"
"`open` Restore a conversation.\n"
"`comment <text>` Leave a comment.\n",
)
def test_archive(self) -> None: def test_archive(self) -> None:
with self.mock_config_info({'api_key': "TEST"}): with self.mock_config_info({'api_key': "TEST"}):
@ -94,6 +97,9 @@ class TestFrontBotWrongTopic(BotTestCase, DefaultTests):
def test_no_conversation_id(self) -> None: def test_no_conversation_id(self) -> None:
with self.mock_config_info({'api_key': "TEST"}): with self.mock_config_info({'api_key': "TEST"}):
self.verify_reply('archive', "No coversation ID found. Please make " self.verify_reply(
"sure that the name of the topic " 'archive',
"contains a valid coversation ID.") "No coversation ID found. Please make "
"sure that the name of the topic "
"contains a valid coversation ID.",
)

View file

@ -26,12 +26,7 @@ class MockModel:
def __init__(self) -> None: def __init__(self) -> None:
self.current_board = 'mock board' self.current_board = 'mock board'
def make_move( def make_move(self, move: str, player: int, is_computer: bool = False) -> Any:
self,
move: str,
player: int,
is_computer: bool = False
) -> Any:
if not is_computer: if not is_computer:
if int(move.replace('move ', '')) < 9: if int(move.replace('move ', '')) < 9:
return 'mock board' return 'mock board'
@ -67,7 +62,7 @@ class GameHandlerBotHandler(GameAdapter):
gameMessageHandler, gameMessageHandler,
rules, rules,
max_players=2, max_players=2,
supports_computer=True supports_computer=True,
) )

View file

@ -16,7 +16,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
user_name: str = 'foo', user_name: str = 'foo',
type: str = 'private', type: str = 'private',
stream: str = '', stream: str = '',
subject: str = '' subject: str = '',
) -> Dict[str, str]: ) -> Dict[str, str]:
message = dict( message = dict(
sender_email=user, sender_email=user,
@ -38,7 +38,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
user_name: str = 'foo', user_name: str = 'foo',
stream: str = '', stream: str = '',
subject: str = '', subject: str = '',
max_messages: int = 20 max_messages: int = 20,
) -> None: ) -> None:
''' '''
This function serves a similar purpose This function serves a similar purpose
@ -52,15 +52,12 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
_b, bot_handler = self._get_handlers() _b, bot_handler = self._get_handlers()
type = 'private' if stream == '' else 'stream' type = 'private' if stream == '' else 'stream'
message = self.make_request_message( message = self.make_request_message(
request, user_name + '@example.com', user_name, type, stream, subject) request, user_name + '@example.com', user_name, type, stream, subject
)
bot_handler.reset_transcript() bot_handler.reset_transcript()
bot.handle_message(message, bot_handler) bot.handle_message(message, bot_handler)
responses = [ responses = [message for (method, message) in bot_handler.transcript]
message
for (method, message)
in bot_handler.transcript
]
first_response = responses[response_number] first_response = responses[response_number]
self.assertEqual(expected_response, first_response['content']) self.assertEqual(expected_response, first_response['content'])
self.assertLessEqual(len(responses), max_messages) self.assertLessEqual(len(responses), max_messages)
@ -70,12 +67,19 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
bot, bot_handler = self._get_handlers() bot, bot_handler = self._get_handlers()
message = { message = {
'sender_email': '{}@example.com'.format(name), 'sender_email': '{}@example.com'.format(name),
'sender_full_name': '{}'.format(name) 'sender_full_name': '{}'.format(name),
} }
bot.add_user_to_cache(message) bot.add_user_to_cache(message)
return bot return bot
def setup_game(self, id: str = '', bot: Any = None, players: List[str] = ['foo', 'baz'], subject: str = 'test game', stream: str = 'test') -> Any: def setup_game(
self,
id: str = '',
bot: Any = None,
players: List[str] = ['foo', 'baz'],
subject: str = 'test game',
stream: str = 'test',
) -> Any:
if bot is None: if bot is None:
bot, bot_handler = self._get_handlers() bot, bot_handler = self._get_handlers()
for p in players: for p in players:
@ -84,8 +88,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
game_id = 'abc123' game_id = 'abc123'
if id != '': if id != '':
game_id = id game_id = id
instance = GameInstance(bot, False, subject, instance = GameInstance(bot, False, subject, game_id, players_emails, stream)
game_id, players_emails, stream)
bot.instances.update({game_id: instance}) bot.instances.update({game_id: instance})
instance.turn = -1 instance.turn = -1
instance.start() instance.start()
@ -95,8 +98,9 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
bot = self.add_user_to_cache('foo') bot = self.add_user_to_cache('foo')
bot.email = 'test-bot@example.com' bot.email = 'test-bot@example.com'
self.add_user_to_cache('test-bot', bot) self.add_user_to_cache('test-bot', bot)
instance = GameInstance(bot, False, 'test game', 'abc123', [ instance = GameInstance(
'foo@example.com', 'test-bot@example.com'], 'test') bot, False, 'test game', 'abc123', ['foo@example.com', 'test-bot@example.com'], 'test'
)
bot.instances.update({'abc123': instance}) bot.instances.update({'abc123': instance})
instance.start() instance.start()
return bot return bot
@ -132,41 +136,55 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
self.verify_response('foo bar baz', self.help_message(), 0) self.verify_response('foo bar baz', self.help_message(), 0)
def test_exception_handling(self) -> None: def test_exception_handling(self) -> None:
with patch('logging.exception'), \ with patch('logging.exception'), patch(
patch('zulip_bots.game_handler.GameAdapter.command_quit', 'zulip_bots.game_handler.GameAdapter.command_quit', side_effect=Exception
side_effect=Exception): ):
self.verify_response('quit', 'Error .', 0) self.verify_response('quit', 'Error .', 0)
def test_not_in_game_messages(self) -> None: def test_not_in_game_messages(self) -> None:
self.verify_response( self.verify_response(
'move 3', 'You are not in a game at the moment. Type `help` for help.', 0, max_messages=1) 'move 3',
'You are not in a game at the moment. Type `help` for help.',
0,
max_messages=1,
)
self.verify_response( self.verify_response(
'quit', 'You are not in a game. Type `help` for all commands.', 0, max_messages=1) 'quit', 'You are not in a game. Type `help` for all commands.', 0, max_messages=1
)
def test_start_game_with_name(self) -> None: def test_start_game_with_name(self) -> None:
bot = self.add_user_to_cache('baz') bot = self.add_user_to_cache('baz')
self.verify_response('start game with @**baz**', self.verify_response(
'You\'ve sent an invitation to play foo test game with @**baz**', 1, bot=bot) 'start game with @**baz**',
'You\'ve sent an invitation to play foo test game with @**baz**',
1,
bot=bot,
)
self.assertEqual(len(bot.invites), 1) self.assertEqual(len(bot.invites), 1)
def test_start_game_with_email(self) -> None: def test_start_game_with_email(self) -> None:
bot = self.add_user_to_cache('baz') bot = self.add_user_to_cache('baz')
self.verify_response('start game with baz@example.com', self.verify_response(
'You\'ve sent an invitation to play foo test game with @**baz**', 1, bot=bot) 'start game with baz@example.com',
'You\'ve sent an invitation to play foo test game with @**baz**',
1,
bot=bot,
)
self.assertEqual(len(bot.invites), 1) self.assertEqual(len(bot.invites), 1)
def test_join_game_and_start_in_stream(self) -> None: def test_join_game_and_start_in_stream(self) -> None:
bot = self.add_user_to_cache('baz') bot = self.add_user_to_cache('baz')
self.add_user_to_cache('foo', bot) self.add_user_to_cache('foo', bot)
bot.invites = { bot.invites = {'abc': {'stream': 'test', 'subject': 'test game', 'host': 'foo@example.com'}}
'abc': { self.verify_response(
'stream': 'test', 'join',
'subject': 'test game', '@**baz** has joined the game',
'host': 'foo@example.com' 0,
} bot=bot,
} stream='test',
self.verify_response('join', '@**baz** has joined the game', 0, bot=bot, subject='test game',
stream='test', subject='test game', user_name='baz') user_name='baz',
)
self.assertEqual(len(bot.instances.keys()), 1) self.assertEqual(len(bot.instances.keys()), 1)
def test_start_game_in_stream(self) -> None: def test_start_game_in_stream(self) -> None:
@ -175,7 +193,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
'**foo** wants to play **foo test game**. Type @**test-bot** join to play them!', '**foo** wants to play **foo test game**. Type @**test-bot** join to play them!',
0, 0,
stream='test', stream='test',
subject='test game' subject='test game',
) )
def test_start_invite_game_in_stream(self) -> None: def test_start_invite_game_in_stream(self) -> None:
@ -186,12 +204,19 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
2, 2,
bot=bot, bot=bot,
stream='test', stream='test',
subject='game test' subject='game test',
) )
def test_join_no_game(self) -> None: def test_join_no_game(self) -> None:
self.verify_response('join', 'There is not a game in this subject. Type `help` for all commands.', self.verify_response(
0, stream='test', subject='test game', user_name='baz', max_messages=1) 'join',
'There is not a game in this subject. Type `help` for all commands.',
0,
stream='test',
subject='test game',
user_name='baz',
max_messages=1,
)
def test_accept_invitation(self) -> None: def test_accept_invitation(self) -> None:
bot = self.add_user_to_cache('baz') bot = self.add_user_to_cache('baz')
@ -201,80 +226,96 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
'subject': '###private###', 'subject': '###private###',
'stream': 'games', 'stream': 'games',
'host': 'foo@example.com', 'host': 'foo@example.com',
'baz@example.com': 'p' 'baz@example.com': 'p',
} }
} }
self.verify_response( self.verify_response(
'accept', 'Accepted invitation to play **foo test game** from @**foo**.', 0, bot, 'baz') 'accept', 'Accepted invitation to play **foo test game** from @**foo**.', 0, bot, 'baz'
)
def test_decline_invitation(self) -> None: def test_decline_invitation(self) -> None:
bot = self.add_user_to_cache('baz') bot = self.add_user_to_cache('baz')
self.add_user_to_cache('foo', bot) self.add_user_to_cache('foo', bot)
bot.invites = { bot.invites = {
'abc': { 'abc': {'subject': '###private###', 'host': 'foo@example.com', 'baz@example.com': 'p'}
'subject': '###private###',
'host': 'foo@example.com',
'baz@example.com': 'p'
}
} }
self.verify_response( self.verify_response(
'decline', 'Declined invitation to play **foo test game** from @**foo**.', 0, bot, 'baz') 'decline', 'Declined invitation to play **foo test game** from @**foo**.', 0, bot, 'baz'
)
def test_quit_invite(self) -> None: def test_quit_invite(self) -> None:
bot = self.add_user_to_cache('foo') bot = self.add_user_to_cache('foo')
bot.invites = { bot.invites = {'abc': {'subject': '###private###', 'host': 'foo@example.com'}}
'abc': { self.verify_response('quit', 'Game cancelled.\n**foo** quit.', 0, bot, 'foo')
'subject': '###private###',
'host': 'foo@example.com'
}
}
self.verify_response(
'quit', 'Game cancelled.\n**foo** quit.', 0, bot, 'foo')
def test_user_already_in_game_errors(self) -> None: def test_user_already_in_game_errors(self) -> None:
bot = self.setup_game() bot = self.setup_game()
self.verify_response('start game with @**baz**',
'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1)
self.verify_response( self.verify_response(
'start game', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, stream='test', max_messages=1) 'start game with @**baz**',
'You are already in a game. Type `quit` to leave.',
0,
bot=bot,
max_messages=1,
)
self.verify_response( self.verify_response(
'accept', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1) 'start game',
'You are already in a game. Type `quit` to leave.',
0,
bot=bot,
stream='test',
max_messages=1,
)
self.verify_response( self.verify_response(
'decline', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1) 'accept', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1
)
self.verify_response( self.verify_response(
'join', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1) 'decline',
'You are already in a game. Type `quit` to leave.',
0,
bot=bot,
max_messages=1,
)
self.verify_response(
'join', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1
)
def test_register_command(self) -> None: def test_register_command(self) -> None:
bot = self.add_user_to_cache('foo') bot = self.add_user_to_cache('foo')
self.verify_response( self.verify_response('register', 'Hello @**foo**. Thanks for registering!', 0, bot, 'foo')
'register', 'Hello @**foo**. Thanks for registering!', 0, bot, 'foo')
self.assertIn('foo@example.com', bot.user_cache.keys()) self.assertIn('foo@example.com', bot.user_cache.keys())
def test_no_active_invite_errors(self) -> None: def test_no_active_invite_errors(self) -> None:
self.verify_response( self.verify_response('accept', 'No active invites. Type `help` for commands.', 0)
'accept', 'No active invites. Type `help` for commands.', 0) self.verify_response('decline', 'No active invites. Type `help` for commands.', 0)
self.verify_response(
'decline', 'No active invites. Type `help` for commands.', 0)
def test_wrong_number_of_players_message(self) -> None: def test_wrong_number_of_players_message(self) -> None:
bot = self.add_user_to_cache('baz') bot = self.add_user_to_cache('baz')
bot.min_players = 5 bot.min_players = 5
self.verify_response('start game with @**baz**', self.verify_response(
'You must have at least 5 players to play.\nGame cancelled.', 0, bot=bot) 'start game with @**baz**',
'You must have at least 5 players to play.\nGame cancelled.',
0,
bot=bot,
)
bot.min_players = 2 bot.min_players = 2
bot.max_players = 1 bot.max_players = 1
self.verify_response('start game with @**baz**', self.verify_response(
'The maximum number of players for this game is 1.', 0, bot=bot) 'start game with @**baz**',
'The maximum number of players for this game is 1.',
0,
bot=bot,
)
bot.max_players = 1 bot.max_players = 1
bot.invites = { bot.invites = {'abc': {'stream': 'test', 'subject': 'test game', 'host': 'foo@example.com'}}
'abc': { self.verify_response(
'stream': 'test', 'join',
'subject': 'test game', 'This game is full.',
'host': 'foo@example.com' 0,
} bot=bot,
} stream='test',
self.verify_response('join', 'This game is full.', 0, bot=bot, subject='test game',
stream='test', subject='test game', user_name='baz') user_name='baz',
)
def test_public_accept(self) -> None: def test_public_accept(self) -> None:
bot = self.add_user_to_cache('baz') bot = self.add_user_to_cache('baz')
@ -284,70 +325,125 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
'stream': 'test', 'stream': 'test',
'subject': 'test game', 'subject': 'test game',
'host': 'baz@example.com', 'host': 'baz@example.com',
'foo@example.com': 'p' 'foo@example.com': 'p',
} }
} }
self.verify_response('accept', '@**foo** has accepted the invitation.', self.verify_response(
0, bot=bot, stream='test', subject='test game') 'accept',
'@**foo** has accepted the invitation.',
0,
bot=bot,
stream='test',
subject='test game',
)
def test_start_game_with_computer(self) -> None: def test_start_game_with_computer(self) -> None:
self.verify_response('start game with @**test-bot**', self.verify_response(
'Wait... That\'s me!', 4, stream='test', subject='test game') 'start game with @**test-bot**',
'Wait... That\'s me!',
4,
stream='test',
subject='test game',
)
def test_sent_by_bot(self) -> None: def test_sent_by_bot(self) -> None:
with self.assertRaises(IndexError): with self.assertRaises(IndexError):
self.verify_response( self.verify_response(
'foo', '', 0, user_name='test-bot', stream='test', subject='test game') 'foo', '', 0, user_name='test-bot', stream='test', subject='test game'
)
def test_forfeit(self) -> None: def test_forfeit(self) -> None:
bot = self.setup_game() bot = self.setup_game()
self.verify_response('forfeit', '**foo** forfeited!', self.verify_response(
0, bot=bot, stream='test', subject='test game') 'forfeit', '**foo** forfeited!', 0, bot=bot, stream='test', subject='test game'
)
def test_draw(self) -> None: def test_draw(self) -> None:
bot = self.setup_game() bot = self.setup_game()
self.verify_response('draw', '**foo** has voted for a draw!\nType `draw` to accept', self.verify_response(
0, bot=bot, stream='test', subject='test game') 'draw',
self.verify_response('draw', 'It was a draw!', 0, bot=bot, stream='test', '**foo** has voted for a draw!\nType `draw` to accept',
subject='test game', user_name='baz') 0,
bot=bot,
stream='test',
subject='test game',
)
self.verify_response(
'draw',
'It was a draw!',
0,
bot=bot,
stream='test',
subject='test game',
user_name='baz',
)
def test_normal_turns(self) -> None: def test_normal_turns(self) -> None:
bot = self.setup_game() bot = self.setup_game()
self.verify_response('move 3', '**foo** moved in column 3\n\nfoo\n\nIt\'s **baz**\'s (:red_circle:) turn.', self.verify_response(
0, bot=bot, stream='test', subject='test game') 'move 3',
self.verify_response('move 3', '**baz** moved in column 3\n\nfoo\n\nIt\'s **foo**\'s (:blue_circle:) turn.', '**foo** moved in column 3\n\nfoo\n\nIt\'s **baz**\'s (:red_circle:) turn.',
0, bot=bot, stream='test', subject='test game', user_name='baz') 0,
bot=bot,
stream='test',
subject='test game',
)
self.verify_response(
'move 3',
'**baz** moved in column 3\n\nfoo\n\nIt\'s **foo**\'s (:blue_circle:) turn.',
0,
bot=bot,
stream='test',
subject='test game',
user_name='baz',
)
def test_wrong_turn(self) -> None: def test_wrong_turn(self) -> None:
bot = self.setup_game() bot = self.setup_game()
self.verify_response('move 5', 'It\'s **foo**\'s (:blue_circle:) turn.', 0, self.verify_response(
bot=bot, stream='test', subject='test game', user_name='baz') 'move 5',
'It\'s **foo**\'s (:blue_circle:) turn.',
0,
bot=bot,
stream='test',
subject='test game',
user_name='baz',
)
def test_private_message_error(self) -> None: def test_private_message_error(self) -> None:
self.verify_response( self.verify_response(
'start game', 'If you are starting a game in private messages, you must invite players. Type `help` for commands.', 0, max_messages=1) 'start game',
'If you are starting a game in private messages, you must invite players. Type `help` for commands.',
0,
max_messages=1,
)
bot = self.add_user_to_cache('bar') bot = self.add_user_to_cache('bar')
bot.invites = { bot.invites = {
'abcdefg': { 'abcdefg': {'host': 'bar@example.com', 'stream': 'test', 'subject': 'test game'}
'host': 'bar@example.com',
'stream': 'test',
'subject': 'test game'
}
} }
self.verify_response( self.verify_response(
'join', 'You cannot join games in private messages. Type `help` for all commands.', 0, bot=bot, max_messages=1) 'join',
'You cannot join games in private messages. Type `help` for all commands.',
0,
bot=bot,
max_messages=1,
)
def test_game_already_in_subject(self) -> None: def test_game_already_in_subject(self) -> None:
bot = self.add_user_to_cache('foo') bot = self.add_user_to_cache('foo')
bot.invites = { bot.invites = {
'abcdefg': { 'abcdefg': {'host': 'foo@example.com', 'stream': 'test', 'subject': 'test game'}
'host': 'foo@example.com',
'stream': 'test',
'subject': 'test game'
}
} }
self.verify_response('start game', 'There is already a game in this stream.', 0, self.verify_response(
bot=bot, stream='test', subject='test game', user_name='baz', max_messages=1) 'start game',
'There is already a game in this stream.',
0,
bot=bot,
stream='test',
subject='test game',
user_name='baz',
max_messages=1,
)
# def test_not_authorized(self) -> None: # def test_not_authorized(self) -> None:
# bot = self.setup_game() # bot = self.setup_game()
@ -355,32 +451,46 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
# user_name='bar', stream='test', subject='test game', max_messages=1) # user_name='bar', stream='test', subject='test game', max_messages=1)
def test_unknown_user(self) -> None: def test_unknown_user(self) -> None:
self.verify_response('start game with @**bar**', self.verify_response(
'I don\'t know @**bar**. Tell them to say @**test-bot** register', 0) 'start game with @**bar**',
self.verify_response('start game with bar@example.com', 'I don\'t know @**bar**. Tell them to say @**test-bot** register',
'I don\'t know bar@example.com. Tell them to use @**test-bot** register', 0) 0,
)
self.verify_response(
'start game with bar@example.com',
'I don\'t know bar@example.com. Tell them to use @**test-bot** register',
0,
)
def test_is_user_not_player(self) -> None: def test_is_user_not_player(self) -> None:
bot = self.add_user_to_cache('foo') bot = self.add_user_to_cache('foo')
self.add_user_to_cache('baz', bot) self.add_user_to_cache('baz', bot)
bot.invites = { bot.invites = {'abcdefg': {'host': 'foo@example.com', 'baz@example.com': 'a'}}
'abcdefg': {
'host': 'foo@example.com',
'baz@example.com': 'a'
}
}
self.assertFalse(bot.is_user_not_player('foo@example.com')) self.assertFalse(bot.is_user_not_player('foo@example.com'))
self.assertFalse(bot.is_user_not_player('baz@example.com')) self.assertFalse(bot.is_user_not_player('baz@example.com'))
def test_move_help_message(self) -> None: def test_move_help_message(self) -> None:
bot = self.setup_game() bot = self.setup_game()
self.verify_response('move 123', '* To make your move during a game, type\n```move <column-number>```', self.verify_response(
0, bot=bot, stream='test', subject='test game') 'move 123',
'* To make your move during a game, type\n```move <column-number>```',
0,
bot=bot,
stream='test',
subject='test game',
)
def test_invalid_move_message(self) -> None: def test_invalid_move_message(self) -> None:
bot = self.setup_game() bot = self.setup_game()
self.verify_response('move 9', 'Invalid Move.', 0, self.verify_response(
bot=bot, stream='test', subject='test game', max_messages=2) 'move 9',
'Invalid Move.',
0,
bot=bot,
stream='test',
subject='test game',
max_messages=2,
)
def test_get_game_id_by_email(self) -> None: def test_get_game_id_by_email(self) -> None:
bot = self.setup_game() bot = self.setup_game()
@ -389,9 +499,13 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
def test_game_over_and_leaderboard(self) -> None: def test_game_over_and_leaderboard(self) -> None:
bot = self.setup_game() bot = self.setup_game()
bot.put_user_cache() bot.put_user_cache()
with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='foo@example.com'): with patch(
self.verify_response('move 3', '**foo** won! :tada:', 'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
1, bot=bot, stream='test', subject='test game') return_value='foo@example.com',
):
self.verify_response(
'move 3', '**foo** won! :tada:', 1, bot=bot, stream='test', subject='test game'
)
leaderboard = '**Most wins**\n\n\ leaderboard = '**Most wins**\n\n\
Player | Games Won | Games Drawn | Games Lost | Total Games\n\ Player | Games Won | Games Drawn | Games Lost | Total Games\n\
--- | --- | --- | --- | --- \n\ --- | --- | --- | --- | --- \n\
@ -402,27 +516,54 @@ Player | Games Won | Games Drawn | Games Lost | Total Games\n\
def test_current_turn_winner(self) -> None: def test_current_turn_winner(self) -> None:
bot = self.setup_game() bot = self.setup_game()
with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='current turn'): with patch(
self.verify_response('move 3', '**foo** won! :tada:', 'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
1, bot=bot, stream='test', subject='test game') return_value='current turn',
):
self.verify_response(
'move 3', '**foo** won! :tada:', 1, bot=bot, stream='test', subject='test game'
)
def test_computer_turn(self) -> None: def test_computer_turn(self) -> None:
bot = self.setup_computer_game() bot = self.setup_computer_game()
self.verify_response('move 3', '**foo** moved in column 3\n\nfoo\n\nIt\'s **test-bot**\'s (:red_circle:) turn.', self.verify_response(
0, bot=bot, stream='test', subject='test game') 'move 3',
with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='test-bot@example.com'): '**foo** moved in column 3\n\nfoo\n\nIt\'s **test-bot**\'s (:red_circle:) turn.',
self.verify_response('move 5', 'I won! Well Played!', 0,
2, bot=bot, stream='test', subject='test game') bot=bot,
stream='test',
subject='test game',
)
with patch(
'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
return_value='test-bot@example.com',
):
self.verify_response(
'move 5', 'I won! Well Played!', 2, bot=bot, stream='test', subject='test game'
)
def test_computer_endgame_responses(self) -> None: def test_computer_endgame_responses(self) -> None:
bot = self.setup_computer_game() bot = self.setup_computer_game()
with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='foo@example.com'): with patch(
self.verify_response('move 5', 'You won! Nice!', 'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
2, bot=bot, stream='test', subject='test game') return_value='foo@example.com',
):
self.verify_response(
'move 5', 'You won! Nice!', 2, bot=bot, stream='test', subject='test game'
)
bot = self.setup_computer_game() bot = self.setup_computer_game()
with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='draw'): with patch(
self.verify_response('move 5', 'It was a draw! Well Played!', 'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
2, bot=bot, stream='test', subject='test game') return_value='draw',
):
self.verify_response(
'move 5',
'It was a draw! Well Played!',
2,
bot=bot,
stream='test',
subject='test game',
)
def test_add_user_statistics(self) -> None: def test_add_user_statistics(self) -> None:
bot = self.add_user_to_cache('foo') bot = self.add_user_to_cache('foo')
@ -448,54 +589,82 @@ Player | Games Won | Games Drawn | Games Lost | Total Games\n\
'host': 'foo@example.com', 'host': 'foo@example.com',
'baz@example.com': 'a', 'baz@example.com': 'a',
'stream': 'test', 'stream': 'test',
'subject': 'test game' 'subject': 'test game',
} }
} }
self.assertEqual(bot.get_game_info('abcdefg'), { self.assertEqual(
'game_id': 'abcdefg', bot.get_game_info('abcdefg'),
'type': 'invite', {
'stream': 'test', 'game_id': 'abcdefg',
'subject': 'test game', 'type': 'invite',
'players': ['foo@example.com', 'baz@example.com'] 'stream': 'test',
}) 'subject': 'test game',
'players': ['foo@example.com', 'baz@example.com'],
},
)
def test_parse_message(self) -> None: def test_parse_message(self) -> None:
bot = self.setup_game() bot = self.setup_game()
self.verify_response('move 3', 'Join your game using the link below!\n\n> **Game `abc123`**\n\ self.verify_response(
'move 3',
'Join your game using the link below!\n\n> **Game `abc123`**\n\
> foo test game\n\ > foo test game\n\
> 2/2 players\n\ > 2/2 players\n\
> **[Join Game](/#narrow/stream/test/topic/test game)**', 0, bot=bot) > **[Join Game](/#narrow/stream/test/topic/test game)**',
0,
bot=bot,
)
bot = self.setup_game() bot = self.setup_game()
self.verify_response('move 3', '''Your current game is not in this subject. \n\ self.verify_response(
'move 3',
'''Your current game is not in this subject. \n\
To move subjects, send your message again, otherwise join the game using the link below. To move subjects, send your message again, otherwise join the game using the link below.
> **Game `abc123`** > **Game `abc123`**
> foo test game > foo test game
> 2/2 players > 2/2 players
> **[Join Game](/#narrow/stream/test/topic/test game)**''', 0, bot=bot, stream='test 2', subject='game 2') > **[Join Game](/#narrow/stream/test/topic/test game)**''',
self.verify_response('move 3', 'foo', 0, bot=bot, 0,
stream='test 2', subject='game 2') bot=bot,
stream='test 2',
subject='game 2',
)
self.verify_response('move 3', 'foo', 0, bot=bot, stream='test 2', subject='game 2')
def test_change_game_subject(self) -> None: def test_change_game_subject(self) -> None:
bot = self.setup_game('abc123') bot = self.setup_game('abc123')
self.setup_game('abcdefg', bot, ['bar', 'abc'], 'test game 2', 'test2') self.setup_game('abcdefg', bot, ['bar', 'abc'], 'test game 2', 'test2')
self.verify_response('move 3', '''Your current game is not in this subject. \n\ self.verify_response(
'move 3',
'''Your current game is not in this subject. \n\
To move subjects, send your message again, otherwise join the game using the link below. To move subjects, send your message again, otherwise join the game using the link below.
> **Game `abcdefg`** > **Game `abcdefg`**
> foo test game > foo test game
> 2/2 players > 2/2 players
> **[Join Game](/#narrow/stream/test2/topic/test game 2)**''', 0, bot=bot, user_name='bar', stream='test game', subject='test2') > **[Join Game](/#narrow/stream/test2/topic/test game 2)**''',
self.verify_response('move 3', 'There is already a game in this subject.', 0,
0, bot=bot, user_name='bar', stream='test game', subject='test') bot=bot,
user_name='bar',
stream='test game',
subject='test2',
)
self.verify_response(
'move 3',
'There is already a game in this subject.',
0,
bot=bot,
user_name='bar',
stream='test game',
subject='test',
)
bot.invites = { bot.invites = {
'foo bar baz': { 'foo bar baz': {
'host': 'foo@example.com', 'host': 'foo@example.com',
'baz@example.com': 'a', 'baz@example.com': 'a',
'stream': 'test', 'stream': 'test',
'subject': 'test game' 'subject': 'test game',
} }
} }
bot.change_game_subject('foo bar baz', 'test2', bot.change_game_subject('foo bar baz', 'test2', 'game2', self.make_request_message('foo'))
'game2', self.make_request_message('foo'))
self.assertEqual(bot.invites['foo bar baz']['stream'], 'test2') self.assertEqual(bot.invites['foo bar baz']['stream'], 'test2')

View file

@ -6,13 +6,9 @@ from zulip_bots.game_handler import BadMoveException, GameAdapter
class GameOfFifteenModel: class GameOfFifteenModel:
final_board = [[0, 1, 2], final_board = [[0, 1, 2], [3, 4, 5], [6, 7, 8]]
[3, 4, 5],
[6, 7, 8]]
initial_board = [[8, 7, 6], initial_board = [[8, 7, 6], [5, 4, 3], [2, 1, 0]]
[5, 4, 3],
[2, 1, 0]]
def __init__(self, board: Any = None) -> None: def __init__(self, board: Any = None) -> None:
if board is not None: if board is not None:
@ -41,7 +37,7 @@ class GameOfFifteenModel:
def won(self, board: Any) -> bool: def won(self, board: Any) -> bool:
for i in range(3): for i in range(3):
for j in range(3): for j in range(3):
if (board[i][j] != self.final_board[i][j]): if board[i][j] != self.final_board[i][j]:
return False return False
return True return True
@ -67,23 +63,26 @@ class GameOfFifteenModel:
if tile not in coordinates: if tile not in coordinates:
raise BadMoveException('You can only move tiles which exist in the board.') raise BadMoveException('You can only move tiles which exist in the board.')
i, j = coordinates[tile] i, j = coordinates[tile]
if (j-1) > -1 and board[i][j-1] == 0: if (j - 1) > -1 and board[i][j - 1] == 0:
board[i][j-1] = tile board[i][j - 1] = tile
board[i][j] = 0 board[i][j] = 0
elif (i-1) > -1 and board[i-1][j] == 0: elif (i - 1) > -1 and board[i - 1][j] == 0:
board[i-1][j] = tile board[i - 1][j] = tile
board[i][j] = 0 board[i][j] = 0
elif (j+1) < 3 and board[i][j+1] == 0: elif (j + 1) < 3 and board[i][j + 1] == 0:
board[i][j+1] = tile board[i][j + 1] = tile
board[i][j] = 0 board[i][j] = 0
elif (i+1) < 3 and board[i+1][j] == 0: elif (i + 1) < 3 and board[i + 1][j] == 0:
board[i+1][j] = tile board[i + 1][j] = tile
board[i][j] = 0 board[i][j] = 0
else: else:
raise BadMoveException('You can only move tiles which are adjacent to :grey_question:.') raise BadMoveException(
'You can only move tiles which are adjacent to :grey_question:.'
)
if m == moves - 1: if m == moves - 1:
return board return board
class GameOfFifteenMessageHandler: class GameOfFifteenMessageHandler:
tiles = { tiles = {
@ -113,8 +112,11 @@ class GameOfFifteenMessageHandler:
return original_player + ' moved ' + tile return original_player + ' moved ' + tile
def game_start_message(self) -> str: def game_start_message(self) -> str:
return ("Welcome to Game of Fifteen!" return (
"To make a move, type @-mention `move <tile1> <tile2> ...`") "Welcome to Game of Fifteen!"
"To make a move, type @-mention `move <tile1> <tile2> ...`"
)
class GameOfFifteenBotHandler(GameAdapter): class GameOfFifteenBotHandler(GameAdapter):
''' '''
@ -125,7 +127,9 @@ class GameOfFifteenBotHandler(GameAdapter):
def __init__(self) -> None: def __init__(self) -> None:
game_name = 'Game of Fifteen' game_name = 'Game of Fifteen'
bot_name = 'Game of Fifteen' bot_name = 'Game of Fifteen'
move_help_message = '* To make your move during a game, type\n```move <tile1> <tile2> ...```' move_help_message = (
'* To make your move during a game, type\n```move <tile1> <tile2> ...```'
)
move_regex = r'move [\d{1}\s]+$' move_regex = r'move [\d{1}\s]+$'
model = GameOfFifteenModel model = GameOfFifteenModel
gameMessageHandler = GameOfFifteenMessageHandler gameMessageHandler = GameOfFifteenMessageHandler
@ -145,4 +149,5 @@ class GameOfFifteenBotHandler(GameAdapter):
max_players=1, max_players=1,
) )
handler_class = GameOfFifteenBotHandler handler_class = GameOfFifteenBotHandler

View file

@ -9,20 +9,19 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests):
bot_name = 'game_of_fifteen' bot_name = 'game_of_fifteen'
def make_request_message( def make_request_message(
self, self, content: str, user: str = 'foo@example.com', user_name: str = 'foo'
content: str,
user: str = 'foo@example.com',
user_name: str = 'foo'
) -> Dict[str, str]: ) -> Dict[str, str]:
message = dict( message = dict(sender_email=user, content=content, sender_full_name=user_name)
sender_email=user,
content=content,
sender_full_name=user_name
)
return message return message
# Function that serves similar purpose to BotTestCase.verify_dialog, but allows for multiple responses to be handled # Function that serves similar purpose to BotTestCase.verify_dialog, but allows for multiple responses to be handled
def verify_response(self, request: str, expected_response: str, response_number: int, user: str = 'foo@example.com') -> None: def verify_response(
self,
request: str,
expected_response: str,
response_number: int,
user: str = 'foo@example.com',
) -> None:
''' '''
This function serves a similar purpose This function serves a similar purpose
to BotTestCase.verify_dialog, but allows to BotTestCase.verify_dialog, but allows
@ -36,11 +35,7 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests):
bot.handle_message(message, bot_handler) bot.handle_message(message, bot_handler)
responses = [ responses = [message for (method, message) in bot_handler.transcript]
message
for (method, message)
in bot_handler.transcript
]
first_response = responses[response_number] first_response = responses[response_number]
self.assertEqual(expected_response, first_response['content']) self.assertEqual(expected_response, first_response['content'])
@ -63,23 +58,19 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests):
def test_game_message_handler_responses(self) -> None: def test_game_message_handler_responses(self) -> None:
board = '\n\n:grey_question::one::two:\n\n:three::four::five:\n\n:six::seven::eight:' board = '\n\n:grey_question::one::two:\n\n:three::four::five:\n\n:six::seven::eight:'
bot, bot_handler = self._get_handlers() bot, bot_handler = self._get_handlers()
self.assertEqual(bot.gameMessageHandler.parse_board( self.assertEqual(bot.gameMessageHandler.parse_board(self.winning_board), board)
self.winning_board), board) self.assertEqual(bot.gameMessageHandler.alert_move_message('foo', 'move 1'), 'foo moved 1')
self.assertEqual(bot.gameMessageHandler.alert_move_message( self.assertEqual(
'foo', 'move 1'), 'foo moved 1') bot.gameMessageHandler.game_start_message(),
self.assertEqual(bot.gameMessageHandler.game_start_message( "Welcome to Game of Fifteen!"
), "Welcome to Game of Fifteen!" "To make a move, type @-mention `move <tile1> <tile2> ...`",
"To make a move, type @-mention `move <tile1> <tile2> ...`") )
winning_board = [[0, 1, 2], winning_board = [[0, 1, 2], [3, 4, 5], [6, 7, 8]]
[3, 4, 5],
[6, 7, 8]]
def test_game_of_fifteen_logic(self) -> None: def test_game_of_fifteen_logic(self) -> None:
def confirmAvailableMoves( def confirmAvailableMoves(
good_moves: List[int], good_moves: List[int], bad_moves: List[int], board: List[List[int]]
bad_moves: List[int],
board: List[List[int]]
) -> None: ) -> None:
gameOfFifteenModel.update_board(board) gameOfFifteenModel.update_board(board)
for move in good_moves: for move in good_moves:
@ -92,18 +83,16 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests):
tile: str, tile: str,
token_number: int, token_number: int,
initial_board: List[List[int]], initial_board: List[List[int]],
final_board: List[List[int]] final_board: List[List[int]],
) -> None: ) -> None:
gameOfFifteenModel.update_board(initial_board) gameOfFifteenModel.update_board(initial_board)
test_board = gameOfFifteenModel.make_move( test_board = gameOfFifteenModel.make_move('move ' + tile, token_number)
'move ' + tile, token_number)
self.assertEqual(test_board, final_board) self.assertEqual(test_board, final_board)
def confirmGameOver(board: List[List[int]], result: str) -> None: def confirmGameOver(board: List[List[int]], result: str) -> None:
gameOfFifteenModel.update_board(board) gameOfFifteenModel.update_board(board)
game_over = gameOfFifteenModel.determine_game_over( game_over = gameOfFifteenModel.determine_game_over(['first_player'])
['first_player'])
self.assertEqual(game_over, result) self.assertEqual(game_over, result)
@ -115,62 +104,43 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests):
gameOfFifteenModel = GameOfFifteenModel() gameOfFifteenModel = GameOfFifteenModel()
# Basic Board setups # Basic Board setups
initial_board = [[8, 7, 6], initial_board = [[8, 7, 6], [5, 4, 3], [2, 1, 0]]
[5, 4, 3],
[2, 1, 0]]
sample_board = [[7, 6, 8], sample_board = [[7, 6, 8], [3, 0, 1], [2, 4, 5]]
[3, 0, 1],
[2, 4, 5]]
winning_board = [[0, 1, 2], winning_board = [[0, 1, 2], [3, 4, 5], [6, 7, 8]]
[3, 4, 5],
[6, 7, 8]]
# Test Move Validation Logic # Test Move Validation Logic
confirmAvailableMoves([1, 2, 3, 4, 5, 6, 7, 8], [0, 9, -1], initial_board) confirmAvailableMoves([1, 2, 3, 4, 5, 6, 7, 8], [0, 9, -1], initial_board)
# Test Move Logic # Test Move Logic
confirmMove('1', 0, initial_board, confirmMove('1', 0, initial_board, [[8, 7, 6], [5, 4, 3], [2, 0, 1]])
[[8, 7, 6],
[5, 4, 3],
[2, 0, 1]])
confirmMove('1 2', 0, initial_board, confirmMove('1 2', 0, initial_board, [[8, 7, 6], [5, 4, 3], [0, 2, 1]])
[[8, 7, 6],
[5, 4, 3],
[0, 2, 1]])
confirmMove('1 2 5', 0, initial_board, confirmMove('1 2 5', 0, initial_board, [[8, 7, 6], [0, 4, 3], [5, 2, 1]])
[[8, 7, 6],
[0, 4, 3],
[5, 2, 1]])
confirmMove('1 2 5 4', 0, initial_board, confirmMove('1 2 5 4', 0, initial_board, [[8, 7, 6], [4, 0, 3], [5, 2, 1]])
[[8, 7, 6],
[4, 0, 3],
[5, 2, 1]])
confirmMove('3', 0, sample_board, confirmMove('3', 0, sample_board, [[7, 6, 8], [0, 3, 1], [2, 4, 5]])
[[7, 6, 8],
[0, 3, 1],
[2, 4, 5]])
confirmMove('3 7', 0, sample_board, confirmMove('3 7', 0, sample_board, [[0, 6, 8], [7, 3, 1], [2, 4, 5]])
[[0, 6, 8],
[7, 3, 1],
[2, 4, 5]])
# Test coordinates logic: # Test coordinates logic:
confirm_coordinates(initial_board, {8: (0, 0), confirm_coordinates(
7: (0, 1), initial_board,
6: (0, 2), {
5: (1, 0), 8: (0, 0),
4: (1, 1), 7: (0, 1),
3: (1, 2), 6: (0, 2),
2: (2, 0), 5: (1, 0),
1: (2, 1), 4: (1, 1),
0: (2, 2)}) 3: (1, 2),
2: (2, 0),
1: (2, 1),
0: (2, 2),
},
)
# Test Game Over Logic: # Test Game Over Logic:
confirmGameOver(winning_board, 'current turn') confirmGameOver(winning_board, 'current turn')
@ -183,9 +153,7 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests):
move3 = 'move 23' move3 = 'move 23'
move4 = 'move 0' move4 = 'move 0'
move5 = 'move 1 2' move5 = 'move 1 2'
initial_board = [[8, 7, 6], initial_board = [[8, 7, 6], [5, 4, 3], [2, 1, 0]]
[5, 4, 3],
[2, 1, 0]]
model.update_board(initial_board) model.update_board(initial_board)
with self.assertRaises(BadMoveException): with self.assertRaises(BadMoveException):

View file

@ -19,6 +19,7 @@ class GiphyHandler:
and responds with a message with the GIF based on provided keywords. and responds with a message with the GIF based on provided keywords.
It also responds to private messages. It also responds to private messages.
""" """
def usage(self) -> str: def usage(self) -> str:
return ''' return '''
This plugin allows users to post GIFs provided by Giphy. This plugin allows users to post GIFs provided by Giphy.
@ -28,8 +29,7 @@ class GiphyHandler:
@staticmethod @staticmethod
def validate_config(config_info: Dict[str, str]) -> None: def validate_config(config_info: Dict[str, str]) -> None:
query = {'s': 'Hello', query = {'s': 'Hello', 'api_key': config_info['key']}
'api_key': config_info['key']}
try: try:
data = requests.get(GIPHY_TRANSLATE_API, params=query) data = requests.get(GIPHY_TRANSLATE_API, params=query)
data.raise_for_status() data.raise_for_status()
@ -38,19 +38,17 @@ class GiphyHandler:
except HTTPError as e: except HTTPError as e:
error_message = str(e) error_message = str(e)
if data.status_code == 403: if data.status_code == 403:
error_message += ('This is likely due to an invalid key.\n' error_message += (
'Follow the instructions in doc.md for setting an API key.') 'This is likely due to an invalid key.\n'
'Follow the instructions in doc.md for setting an API key.'
)
raise ConfigValidationError(error_message) raise ConfigValidationError(error_message)
def initialize(self, bot_handler: BotHandler) -> None: def initialize(self, bot_handler: BotHandler) -> None:
self.config_info = bot_handler.get_config_info('giphy') self.config_info = bot_handler.get_config_info('giphy')
def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None:
bot_response = get_bot_giphy_response( bot_response = get_bot_giphy_response(message, bot_handler, self.config_info)
message,
bot_handler,
self.config_info
)
bot_handler.send_reply(message, bot_response) bot_handler.send_reply(message, bot_response)
@ -83,21 +81,26 @@ def get_url_gif_giphy(keyword: str, api_key: str) -> Union[int, str]:
return gif_url return gif_url
def get_bot_giphy_response(message: Dict[str, str], bot_handler: BotHandler, config_info: Dict[str, str]) -> str: def get_bot_giphy_response(
message: Dict[str, str], bot_handler: BotHandler, config_info: Dict[str, str]
) -> str:
# Each exception has a specific reply should "gif_url" return a number. # Each exception has a specific reply should "gif_url" return a number.
# The bot will post the appropriate message for the error. # The bot will post the appropriate message for the error.
keyword = message['content'] keyword = message['content']
try: try:
gif_url = get_url_gif_giphy(keyword, config_info['key']) gif_url = get_url_gif_giphy(keyword, config_info['key'])
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
return ('Uh oh, sorry :slightly_frowning_face:, I ' return (
'cannot process your request right now. But, ' 'Uh oh, sorry :slightly_frowning_face:, I '
'let\'s try again later! :grin:') 'cannot process your request right now. But, '
'let\'s try again later! :grin:'
)
except GiphyNoResultException: except GiphyNoResultException:
return ('Sorry, I don\'t have a GIF for "%s"! ' return 'Sorry, I don\'t have a GIF for "%s"! ' ':astonished:' % (keyword,)
':astonished:' % (keyword,)) return (
return ('[Click to enlarge](%s)' '[Click to enlarge](%s)'
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)' '[](/static/images/interactive-bot/giphy/powered-by-giphy.png)' % (gif_url,)
% (gif_url,)) )
handler_class = GiphyHandler handler_class = GiphyHandler

View file

@ -10,25 +10,28 @@ class TestGiphyBot(BotTestCase, DefaultTests):
# Test for bot response to empty message # Test for bot response to empty message
def test_bot_responds_to_empty_message(self) -> None: def test_bot_responds_to_empty_message(self) -> None:
bot_response = '[Click to enlarge]' \ bot_response = (
'(https://media0.giphy.com/media/ISumMYQyX4sSI/giphy.gif)' \ '[Click to enlarge]'
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)' '(https://media0.giphy.com/media/ISumMYQyX4sSI/giphy.gif)'
with self.mock_config_info({'key': '12345678'}), \ '[](/static/images/interactive-bot/giphy/powered-by-giphy.png)'
self.mock_http_conversation('test_random'): )
with self.mock_config_info({'key': '12345678'}), self.mock_http_conversation('test_random'):
self.verify_reply('', bot_response) self.verify_reply('', bot_response)
def test_normal(self) -> None: def test_normal(self) -> None:
bot_response = '[Click to enlarge]' \ bot_response = (
'(https://media4.giphy.com/media/3o6ZtpxSZbQRRnwCKQ/giphy.gif)' \ '[Click to enlarge]'
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)' '(https://media4.giphy.com/media/3o6ZtpxSZbQRRnwCKQ/giphy.gif)'
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)'
)
with self.mock_config_info({'key': '12345678'}), \ with self.mock_config_info({'key': '12345678'}), self.mock_http_conversation('test_normal'):
self.mock_http_conversation('test_normal'):
self.verify_reply('Hello', bot_response) self.verify_reply('Hello', bot_response)
def test_no_result(self) -> None: def test_no_result(self) -> None:
with self.mock_config_info({'key': '12345678'}), \ with self.mock_config_info({'key': '12345678'}), self.mock_http_conversation(
self.mock_http_conversation('test_no_result'): 'test_no_result'
):
self.verify_reply( self.verify_reply(
'world without zulip', 'world without zulip',
'Sorry, I don\'t have a GIF for "world without zulip"! :astonished:', 'Sorry, I don\'t have a GIF for "world without zulip"! :astonished:',
@ -38,8 +41,9 @@ class TestGiphyBot(BotTestCase, DefaultTests):
get_bot_message_handler(self.bot_name) get_bot_message_handler(self.bot_name)
StubBotHandler() StubBotHandler()
with self.mock_http_conversation('test_403'): with self.mock_http_conversation('test_403'):
self.validate_invalid_config({'key': '12345678'}, self.validate_invalid_config(
"This is likely due to an invalid key.\n") {'key': '12345678'}, "This is likely due to an invalid key.\n"
)
def test_connection_error_when_validate_config(self) -> None: def test_connection_error_when_validate_config(self) -> None:
error = ConnectionError() error = ConnectionError()
@ -53,11 +57,12 @@ class TestGiphyBot(BotTestCase, DefaultTests):
self.validate_valid_config({'key': '12345678'}) self.validate_valid_config({'key': '12345678'})
def test_connection_error_while_running(self) -> None: def test_connection_error_while_running(self) -> None:
with self.mock_config_info({'key': '12345678'}), \ with self.mock_config_info({'key': '12345678'}), patch(
patch('requests.get', side_effect=[ConnectionError()]), \ 'requests.get', side_effect=[ConnectionError()]
patch('logging.exception'): ), patch('logging.exception'):
self.verify_reply( self.verify_reply(
'world without chocolate', 'world without chocolate',
'Uh oh, sorry :slightly_frowning_face:, I ' 'Uh oh, sorry :slightly_frowning_face:, I '
'cannot process your request right now. But, ' 'cannot process your request right now. But, '
'let\'s try again later! :grin:') 'let\'s try again later! :grin:',
)

View file

@ -22,11 +22,13 @@ class GithubHandler:
self.repo = self.config_info.get("repo", False) self.repo = self.config_info.get("repo", False)
def usage(self) -> str: def usage(self) -> str:
return ("This plugin displays details on github issues and pull requests. " return (
"To reference an issue or pull request usename mention the bot then " "This plugin displays details on github issues and pull requests. "
"anytime in the message type its id, for example:\n" "To reference an issue or pull request usename mention the bot then "
"@**Github detail** #3212 zulip#3212 zulip/zulip#3212\n" "anytime in the message type its id, for example:\n"
"The default owner is {} and the default repo is {}.".format(self.owner, self.repo)) "@**Github detail** #3212 zulip#3212 zulip/zulip#3212\n"
"The default owner is {} and the default repo is {}.".format(self.owner, self.repo)
)
def format_message(self, details: Dict[str, Any]) -> str: def format_message(self, details: Dict[str, Any]) -> str:
number = details['number'] number = details['number']
@ -39,17 +41,24 @@ class GithubHandler:
description = details['body'] description = details['body']
status = details['state'].title() status = details['state'].title()
message_string = ('**[{owner}/{repo}#{id}]'.format(owner=owner, repo=repo, id=number), message_string = (
'({link}) - {title}**\n'.format(title=title, link=link), '**[{owner}/{repo}#{id}]'.format(owner=owner, repo=repo, id=number),
'Created by **[{author}](https://github.com/{author})**\n'.format(author=author), '({link}) - {title}**\n'.format(title=title, link=link),
'Status - **{status}**\n```quote\n{description}\n```'.format(status=status, description=description)) 'Created by **[{author}](https://github.com/{author})**\n'.format(author=author),
'Status - **{status}**\n```quote\n{description}\n```'.format(
status=status, description=description
),
)
return ''.join(message_string) return ''.join(message_string)
def get_details_from_github(self, owner: str, repo: str, number: str) -> Union[None, Dict[str, Union[str, int, bool]]]: def get_details_from_github(
self, owner: str, repo: str, number: str
) -> Union[None, Dict[str, Union[str, int, bool]]]:
# Gets the details of an issues or pull request # Gets the details of an issues or pull request
try: try:
r = requests.get( r = requests.get(
self.GITHUB_ISSUE_URL_TEMPLATE.format(owner=owner, repo=repo, id=number)) self.GITHUB_ISSUE_URL_TEMPLATE.format(owner=owner, repo=repo, id=number)
)
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logging.exception(str(e)) logging.exception(str(e))
return None return None
@ -73,8 +82,7 @@ class GithubHandler:
return return
# Capture owner, repo, id # Capture owner, repo, id
issue_prs = list(re.finditer( issue_prs = list(re.finditer(self.HANDLE_MESSAGE_REGEX, message['content']))
self.HANDLE_MESSAGE_REGEX, message['content']))
bot_messages = [] bot_messages = []
if len(issue_prs) > 5: if len(issue_prs) > 5:
# We limit to 5 requests to prevent denial-of-service # We limit to 5 requests to prevent denial-of-service
@ -91,8 +99,11 @@ class GithubHandler:
details['repo'] = repo details['repo'] = repo
bot_messages.append(self.format_message(details)) bot_messages.append(self.format_message(details))
else: else:
bot_messages.append("Failed to find issue/pr: {owner}/{repo}#{id}" bot_messages.append(
.format(owner=owner, repo=repo, id=issue_pr.group(3))) "Failed to find issue/pr: {owner}/{repo}#{id}".format(
owner=owner, repo=repo, id=issue_pr.group(3)
)
)
else: else:
bot_messages.append("Failed to detect owner and repository name.") bot_messages.append("Failed to detect owner and repository name.")
if len(bot_messages) == 0: if len(bot_messages) == 0:
@ -100,4 +111,5 @@ class GithubHandler:
bot_message = '\n'.join(bot_messages) bot_message = '\n'.join(bot_messages)
bot_handler.send_reply(message, bot_message) bot_handler.send_reply(message, bot_message)
handler_class = GithubHandler handler_class = GithubHandler

View file

@ -23,15 +23,17 @@ class TestGithubDetailBot(BotTestCase, DefaultTests):
def test_issue(self) -> None: def test_issue(self) -> None:
request = 'zulip/zulip#5365' request = 'zulip/zulip#5365'
bot_response = '**[zulip/zulip#5365](https://github.com/zulip/zulip/issues/5365)'\ bot_response = (
' - frontend: Enable hot-reloading of CSS in development**\n'\ '**[zulip/zulip#5365](https://github.com/zulip/zulip/issues/5365)'
'Created by **[timabbott](https://github.com/timabbott)**\n'\ ' - frontend: Enable hot-reloading of CSS in development**\n'
'Status - **Open**\n'\ 'Created by **[timabbott](https://github.com/timabbott)**\n'
'```quote\n'\ 'Status - **Open**\n'
'There\'s strong interest among folks working on the frontend in being '\ '```quote\n'
'able to use the hot-reloading feature of webpack for managing our CSS.\r\n\r\n'\ 'There\'s strong interest among folks working on the frontend in being '
'In order to do this, step 1 is to move our CSS minification pipeline '\ 'able to use the hot-reloading feature of webpack for managing our CSS.\r\n\r\n'
'from django-pipeline to Webpack. \n```' 'In order to do this, step 1 is to move our CSS minification pipeline '
'from django-pipeline to Webpack. \n```'
)
with self.mock_http_conversation('test_issue'): with self.mock_http_conversation('test_issue'):
with self.mock_config_info(self.mock_config): with self.mock_config_info(self.mock_config):
@ -39,18 +41,20 @@ class TestGithubDetailBot(BotTestCase, DefaultTests):
def test_pull_request(self) -> None: def test_pull_request(self) -> None:
request = 'zulip/zulip#5345' request = 'zulip/zulip#5345'
bot_response = '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'\ bot_response = (
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'\ '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'\ ' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'
'Status - **Open**\n```quote\nAn interaction bug (#4811) '\ 'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'
'between our settings UI and the bootstrap modals breaks hotkey '\ 'Status - **Open**\n```quote\nAn interaction bug (#4811) '
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'\ 'between our settings UI and the bootstrap modals breaks hotkey '
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'\ 'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '\ ' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'
'using bootstrap for the account settings modal and all other modals,'\ ' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '
' replace with `Modal` class\r\n[] Add hotkey support for closing the'\ 'using bootstrap for the account settings modal and all other modals,'
' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'\ ' replace with `Modal` class\r\n[] Add hotkey support for closing the'
' removing dependencies from Bootstrap.\n```' ' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'
' removing dependencies from Bootstrap.\n```'
)
with self.mock_http_conversation('test_pull'): with self.mock_http_conversation('test_pull'):
with self.mock_config_info(self.mock_config): with self.mock_config_info(self.mock_config):
self.verify_reply(request, bot_response) self.verify_reply(request, bot_response)
@ -77,18 +81,22 @@ class TestGithubDetailBot(BotTestCase, DefaultTests):
def test_help_text(self) -> None: def test_help_text(self) -> None:
request = 'help' request = 'help'
bot_response = 'This plugin displays details on github issues and pull requests. '\ bot_response = (
'To reference an issue or pull request usename mention the bot then '\ 'This plugin displays details on github issues and pull requests. '
'anytime in the message type its id, for example:\n@**Github detail** '\ 'To reference an issue or pull request usename mention the bot then '
'#3212 zulip#3212 zulip/zulip#3212\nThe default owner is zulip and '\ 'anytime in the message type its id, for example:\n@**Github detail** '
'the default repo is zulip.' '#3212 zulip#3212 zulip/zulip#3212\nThe default owner is zulip and '
'the default repo is zulip.'
)
with self.mock_config_info(self.mock_config): with self.mock_config_info(self.mock_config):
self.verify_reply(request, bot_response) self.verify_reply(request, bot_response)
def test_too_many_request(self) -> None: def test_too_many_request(self) -> None:
request = 'zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 '\ request = (
'zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 zulip/zulip#1' 'zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 '
'zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 zulip/zulip#1'
)
bot_response = 'Please ask for <=5 links in any one request' bot_response = 'Please ask for <=5 links in any one request'
with self.mock_config_info(self.mock_config): with self.mock_config_info(self.mock_config):
@ -102,36 +110,40 @@ class TestGithubDetailBot(BotTestCase, DefaultTests):
def test_owner_and_repo_specified_in_config_file(self) -> None: def test_owner_and_repo_specified_in_config_file(self) -> None:
request = '/#5345' request = '/#5345'
bot_response = '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'\ bot_response = (
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'\ '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'\ ' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'
'Status - **Open**\n```quote\nAn interaction bug (#4811) '\ 'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'
'between our settings UI and the bootstrap modals breaks hotkey '\ 'Status - **Open**\n```quote\nAn interaction bug (#4811) '
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'\ 'between our settings UI and the bootstrap modals breaks hotkey '
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'\ 'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '\ ' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'
'using bootstrap for the account settings modal and all other modals,'\ ' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '
' replace with `Modal` class\r\n[] Add hotkey support for closing the'\ 'using bootstrap for the account settings modal and all other modals,'
' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'\ ' replace with `Modal` class\r\n[] Add hotkey support for closing the'
' removing dependencies from Bootstrap.\n```' ' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'
' removing dependencies from Bootstrap.\n```'
)
with self.mock_http_conversation('test_pull'): with self.mock_http_conversation('test_pull'):
with self.mock_config_info(self.mock_config): with self.mock_config_info(self.mock_config):
self.verify_reply(request, bot_response) self.verify_reply(request, bot_response)
def test_owner_and_repo_specified_in_message(self) -> None: def test_owner_and_repo_specified_in_message(self) -> None:
request = 'zulip/zulip#5345' request = 'zulip/zulip#5345'
bot_response = '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'\ bot_response = (
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'\ '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'\ ' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'
'Status - **Open**\n```quote\nAn interaction bug (#4811) '\ 'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'
'between our settings UI and the bootstrap modals breaks hotkey '\ 'Status - **Open**\n```quote\nAn interaction bug (#4811) '
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'\ 'between our settings UI and the bootstrap modals breaks hotkey '
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'\ 'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '\ ' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'
'using bootstrap for the account settings modal and all other modals,'\ ' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '
' replace with `Modal` class\r\n[] Add hotkey support for closing the'\ 'using bootstrap for the account settings modal and all other modals,'
' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'\ ' replace with `Modal` class\r\n[] Add hotkey support for closing the'
' removing dependencies from Bootstrap.\n```' ' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'
' removing dependencies from Bootstrap.\n```'
)
with self.mock_http_conversation('test_pull'): with self.mock_http_conversation('test_pull'):
with self.mock_config_info(self.empty_config): with self.mock_config_info(self.empty_config):
self.verify_reply(request, bot_response) self.verify_reply(request, bot_response)

View file

@ -32,11 +32,11 @@ def google_search(keywords: str) -> List[Dict[str, str]]:
if a.text.strip() == 'Cached' and 'webcache.googleusercontent.com' in a['href']: if a.text.strip() == 'Cached' and 'webcache.googleusercontent.com' in a['href']:
continue continue
# a.text: The name of the page # a.text: The name of the page
result = {'url': "https://www.google.com{}".format(link), result = {'url': "https://www.google.com{}".format(link), 'name': a.text}
'name': a.text}
results.append(result) results.append(result)
return results return results
def get_google_result(search_keywords: str) -> str: def get_google_result(search_keywords: str) -> str:
help_message = "To use this bot, start messages with @mentioned-bot, \ help_message = "To use this bot, start messages with @mentioned-bot, \
followed by what you want to search for. If \ followed by what you want to search for. If \
@ -56,13 +56,14 @@ def get_google_result(search_keywords: str) -> str:
else: else:
try: try:
results = google_search(search_keywords) results = google_search(search_keywords)
if (len(results) == 0): if len(results) == 0:
return "Found no results." return "Found no results."
return "Found Result: [{}]({})".format(results[0]['name'], results[0]['url']) return "Found Result: [{}]({})".format(results[0]['name'], results[0]['url'])
except Exception as e: except Exception as e:
logging.exception(str(e)) logging.exception(str(e))
return 'Error: Search failed. {}.'.format(e) return 'Error: Search failed. {}.'.format(e)
class GoogleSearchHandler: class GoogleSearchHandler:
''' '''
This plugin allows users to enter a search This plugin allows users to enter a search
@ -87,4 +88,5 @@ class GoogleSearchHandler:
result = get_google_result(original_content) result = get_google_result(original_content)
bot_handler.send_reply(message, result) bot_handler.send_reply(message, result)
handler_class = GoogleSearchHandler handler_class = GoogleSearchHandler

View file

@ -11,7 +11,7 @@ class TestGoogleSearchBot(BotTestCase, DefaultTests):
with self.mock_http_conversation('test_normal'): with self.mock_http_conversation('test_normal'):
self.verify_reply( self.verify_reply(
'zulip', 'zulip',
'Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)' 'Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)',
) )
def test_bot_help(self) -> None: def test_bot_help(self) -> None:
@ -31,9 +31,10 @@ class TestGoogleSearchBot(BotTestCase, DefaultTests):
self.verify_reply('no res', 'Found no results.') self.verify_reply('no res', 'Found no results.')
def test_attribute_error(self) -> None: def test_attribute_error(self) -> None:
with self.mock_http_conversation('test_attribute_error'), \ with self.mock_http_conversation('test_attribute_error'), patch('logging.exception'):
patch('logging.exception'): self.verify_reply(
self.verify_reply('test', 'Error: Search failed. \'NoneType\' object has no attribute \'findAll\'.') 'test', 'Error: Search failed. \'NoneType\' object has no attribute \'findAll\'.'
)
# Makes sure cached results, irrelevant links, or empty results are not displayed # Makes sure cached results, irrelevant links, or empty results are not displayed
def test_ignore_links(self) -> None: def test_ignore_links(self) -> None:
@ -43,5 +44,5 @@ class TestGoogleSearchBot(BotTestCase, DefaultTests):
# See test_ignore_links.json # See test_ignore_links.json
self.verify_reply( self.verify_reply(
'zulip', 'zulip',
'Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)' 'Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)',
) )

View file

@ -10,6 +10,7 @@ class GoogleTranslateHandler:
Before using it, make sure you set up google api keys, and enable google Before using it, make sure you set up google api keys, and enable google
cloud translate from the google cloud console. cloud translate from the google cloud console.
''' '''
def usage(self): def usage(self):
return ''' return '''
This plugin allows users translate messages This plugin allows users translate messages
@ -27,12 +28,15 @@ class GoogleTranslateHandler:
bot_handler.quit(str(e)) bot_handler.quit(str(e))
def handle_message(self, message, bot_handler): def handle_message(self, message, bot_handler):
bot_response = get_translate_bot_response(message['content'], bot_response = get_translate_bot_response(
self.config_info, message['content'],
message['sender_full_name'], self.config_info,
self.supported_languages) message['sender_full_name'],
self.supported_languages,
)
bot_handler.send_reply(message, bot_response) bot_handler.send_reply(message, bot_response)
api_url = 'https://translation.googleapis.com/language/translate/v2' api_url = 'https://translation.googleapis.com/language/translate/v2'
help_text = ''' help_text = '''
@ -44,17 +48,20 @@ Visit [here](https://cloud.google.com/translate/docs/languages) for all language
language_not_found_text = '{} language not found. Visit [here](https://cloud.google.com/translate/docs/languages) for all languages' language_not_found_text = '{} language not found. Visit [here](https://cloud.google.com/translate/docs/languages) for all languages'
def get_supported_languages(key): def get_supported_languages(key):
parameters = {'key': key, 'target': 'en'} parameters = {'key': key, 'target': 'en'}
response = requests.get(api_url + '/languages', params = parameters) response = requests.get(api_url + '/languages', params=parameters)
if response.status_code == requests.codes.ok: if response.status_code == requests.codes.ok:
languages = response.json()['data']['languages'] languages = response.json()['data']['languages']
return {lang['name'].lower(): lang['language'].lower() for lang in languages} return {lang['name'].lower(): lang['language'].lower() for lang in languages}
raise TranslateError(response.json()['error']['message']) raise TranslateError(response.json()['error']['message'])
class TranslateError(Exception): class TranslateError(Exception):
pass pass
def translate(text_to_translate, key, dest, src): def translate(text_to_translate, key, dest, src):
parameters = {'q': text_to_translate, 'target': dest, 'key': key} parameters = {'q': text_to_translate, 'target': dest, 'key': key}
if src != '': if src != '':
@ -64,6 +71,7 @@ def translate(text_to_translate, key, dest, src):
return response.json()['data']['translations'][0]['translatedText'] return response.json()['data']['translations'][0]['translatedText']
raise TranslateError(response.json()['error']['message']) raise TranslateError(response.json()['error']['message'])
def get_code_for_language(language, all_languages): def get_code_for_language(language, all_languages):
if language.lower() not in all_languages.values(): if language.lower() not in all_languages.values():
if language.lower() not in all_languages.keys(): if language.lower() not in all_languages.keys():
@ -71,6 +79,7 @@ def get_code_for_language(language, all_languages):
language = all_languages[language.lower()] language = all_languages[language.lower()]
return language return language
def get_translate_bot_response(message_content, config_file, author, all_languages): def get_translate_bot_response(message_content, config_file, author, all_languages):
message_content = message_content.strip() message_content = message_content.strip()
if message_content == 'help' or message_content is None or not message_content.startswith('"'): if message_content == 'help' or message_content is None or not message_content.startswith('"'):
@ -94,7 +103,9 @@ def get_translate_bot_response(message_content, config_file, author, all_languag
if source_language == '': if source_language == '':
return language_not_found_text.format("Source") return language_not_found_text.format("Source")
try: try:
translated_text = translate(text_to_translate, config_file['key'], target_language, source_language) translated_text = translate(
text_to_translate, config_file['key'], target_language, source_language
)
except requests.exceptions.ConnectionError as conn_err: except requests.exceptions.ConnectionError as conn_err:
return "Could not connect to Google Translate. {}.".format(conn_err) return "Could not connect to Google Translate. {}.".format(conn_err)
except TranslateError as tr_err: except TranslateError as tr_err:
@ -103,4 +114,5 @@ def get_translate_bot_response(message_content, config_file, author, all_languag
return "Error. {}.".format(err) return "Error. {}.".format(err)
return "{} (from {})".format(translated_text, author) return "{} (from {})".format(translated_text, author)
handler_class = GoogleTranslateHandler handler_class = GoogleTranslateHandler

Some files were not shown because too many files have changed in this diff Show more