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 = [
# This linter should be first since bash_rules depends on it.
{'pattern': r'\s+$',
'strip': '\n',
'description': 'Fix trailing whitespace'},
{'pattern': '\t',
'strip': '\n',
'description': 'Fix tab-based whitespace'},
{'pattern': r'\s+$', 'strip': '\n', 'description': 'Fix trailing whitespace'},
{'pattern': '\t', 'strip': '\n', 'description': 'Fix tab-based whitespace'},
] # 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.
# This rule finds one space trailing a non-space, three or more trailing spaces, and
# spaces on an empty line.
{'pattern': r'((?<!\s)\s$)|(\s\s\s+$)|(^\s+$)',
{
'pattern': r'((?<!\s)\s$)|(\s\s\s+$)|(^\s+$)',
'strip': '\n',
'description': 'Fix trailing whitespace'},
{'pattern': r'^#+[A-Za-z0-9]',
'description': 'Fix trailing whitespace',
},
{
'pattern': r'^#+[A-Za-z0-9]',
'strip': '\n',
'description': 'Missing space after # in heading'},
'description': 'Missing space after # in heading',
},
]
python_rules = RuleList(
langs=['py'],
rules=[
{'pattern': r'".*"%\([a-z_].*\)?$',
'description': 'Missing space around "%"'},
{'pattern': r"'.*'%\([a-z_].*\)?$",
'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
{'pattern': r" =" + r'[^ =>~"]',
'description': 'Missing whitespace after "="'},
{'pattern': r'":\w[^"]*$',
'description': 'Missing whitespace after ":"'},
{'pattern': r"':\w[^']*$",
'description': 'Missing whitespace after ":"'},
{'pattern': r"^\s+[#]\w",
'strip': '\n',
'description': 'Missing whitespace after "#"'},
{'pattern': r"assertEquals[(]",
'description': 'Use assertEqual, not assertEquals (which is deprecated).'},
{'pattern': r'self: Any',
{'pattern': r" =" + r'[^ =>~"]', 'description': 'Missing whitespace after "="'},
{'pattern': r'":\w[^"]*$', 'description': 'Missing whitespace after ":"'},
{'pattern': r"':\w[^']*$", 'description': 'Missing whitespace after ":"'},
{'pattern': r"^\s+[#]\w", 'strip': '\n', 'description': 'Missing whitespace after "#"'},
{
'pattern': r"assertEquals[(]",
'description': 'Use assertEqual, not assertEquals (which is deprecated).',
},
{
'pattern': r'self: Any',
'description': 'you can omit Any annotation for self',
'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"type:[(]",
'description': 'Missing whitespace after ":" in type annotation'},
{'pattern': r"# type [(]",
'description': 'Missing : after type in type annotation'},
{'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 "("'},
'bad_lines': ['def foo(self: Any):'],
},
{'pattern': r"== None", 'description': 'Use `is None` to check whether something is None'},
{'pattern': r"type:[(]", 'description': 'Missing whitespace after ":" in type annotation'},
{'pattern': r"# type [(]", 'description': 'Missing : after type in type annotation'},
{'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
# rare; if we find any, they can be added to the exclude list for
# this rule.
{'pattern': r' % [a-zA-Z0-9_.]*\)?$',
'description': 'Used % comprehension without a tuple'},
{'pattern': r'.*%s.* % \([a-zA-Z0-9_.]*\)$',
'description': 'Used % comprehension without a tuple'},
{'pattern': r'__future__',
{
'pattern': r' % [a-zA-Z0-9_.]*\)?$',
'description': 'Used % comprehension without a tuple',
},
{
'pattern': r'.*%s.* % \([a-zA-Z0-9_.]*\)$',
'description': 'Used % comprehension without a tuple',
},
{
'pattern': r'__future__',
'include_only': {'zulip_bots/zulip_bots/bots/'},
'description': 'Bots no longer need __future__ imports.'},
{'pattern': r'#!/usr/bin/env python$',
'description': 'Bots no longer need __future__ imports.',
},
{
'pattern': r'#!/usr/bin/env python$',
'include_only': {'zulip_bots/'},
'description': 'Python shebangs must be python3'},
{'pattern': r'(^|\s)open\s*\(',
'description': 'Python shebangs must be python3',
},
{
'pattern': r'(^|\s)open\s*\(',
'description': 'open() should not be used in Zulip\'s bots. Use functions'
' provided by the bots framework to access the filesystem.',
'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\)',
'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\)',
'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.'},
'description': 'Bot test cases should inherit from BotTestCase before DefaultTests.',
},
*whitespace_rules,
],
max_length=140,
@ -99,9 +107,11 @@ python_rules = RuleList(
bash_rules = RuleList(
langs=['sh'],
rules=[
{'pattern': r'#!.*sh [-xe]',
{
'pattern': r'#!.*sh [-xe]',
'description': 'Fix shebang line with proper call to /usr/bin/env for Bash path, change -x|-e switches'
' to set -x|set -e'},
' to set -x|set -e',
},
*whitespace_rules[0:1],
],
)
@ -116,20 +126,27 @@ json_rules = RuleList(
# version of the tab-based whitespace rule (we can't just use
# exclude in whitespace_rules, since we only want to ignore
# JSON files with tab-based whitespace, not webhook code).
rules= whitespace_rules[0:1],
rules=whitespace_rules[0:1],
)
prose_style_rules = [
{'pattern': r'[^\/\#\-"]([jJ]avascript)', # exclude usage in hrefs/divs
'description': "javascript should be spelled JavaScript"},
{'pattern': r'''[^\/\-\."'\_\=\>]([gG]ithub)[^\.\-\_"\<]''', # exclude usage in hrefs/divs
'description': "github should be spelled GitHub"},
{'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."},
{
'pattern': r'[^\/\#\-"]([jJ]avascript)', # exclude usage in hrefs/divs
'description': "javascript should be spelled JavaScript",
},
{
'pattern': r'''[^\/\-\."'\_\=\>]([gG]ithub)[^\.\-\_"\<]''', # exclude usage in hrefs/divs
'description': "github should be spelled GitHub",
},
{
'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]
markdown_docs_length_exclude = {
@ -141,8 +158,10 @@ markdown_rules = RuleList(
rules=[
*markdown_whitespace_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,
length_exclude=markdown_docs_length_exclude,

View file

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

View file

@ -11,84 +11,253 @@ from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
# License: MIT
# Ref: fit_commit/validators/tense.rb
WORD_SET = {
'adds', 'adding', 'added',
'allows', 'allowing', 'allowed',
'amends', 'amending', 'amended',
'bumps', 'bumping', 'bumped',
'calculates', 'calculating', 'calculated',
'changes', 'changing', 'changed',
'cleans', 'cleaning', 'cleaned',
'commits', 'committing', 'committed',
'corrects', 'correcting', 'corrected',
'creates', 'creating', 'created',
'darkens', 'darkening', 'darkened',
'disables', 'disabling', 'disabled',
'displays', 'displaying', 'displayed',
'documents', 'documenting', 'documented',
'drys', 'drying', 'dryed',
'ends', 'ending', 'ended',
'enforces', 'enforcing', 'enforced',
'enqueues', 'enqueuing', 'enqueued',
'extracts', 'extracting', 'extracted',
'finishes', 'finishing', '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',
'adds',
'adding',
'added',
'allows',
'allowing',
'allowed',
'amends',
'amending',
'amended',
'bumps',
'bumping',
'bumped',
'calculates',
'calculating',
'calculated',
'changes',
'changing',
'changed',
'cleans',
'cleaning',
'cleaned',
'commits',
'committing',
'committed',
'corrects',
'correcting',
'corrected',
'creates',
'creating',
'created',
'darkens',
'darkening',
'darkened',
'disables',
'disabling',
'disabled',
'displays',
'displaying',
'displayed',
'documents',
'documenting',
'documented',
'drys',
'drying',
'dryed',
'ends',
'ending',
'ended',
'enforces',
'enforcing',
'enforced',
'enqueues',
'enqueuing',
'enqueued',
'extracts',
'extracting',
'extracted',
'finishes',
'finishing',
'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 = [
'add', 'allow', 'amend', 'bump', 'calculate', 'change', 'clean', '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',
'add',
'allow',
'amend',
'bump',
'calculate',
'change',
'clean',
'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()
def head_binary_search(key: str, words: List[str]) -> str:
""" Find the imperative mood version of `word` by looking at the first
3 characters. """
"""Find the imperative mood version of `word` by looking at the first
3 characters."""
# Edge case: 'disable' and 'display' have the same 3 starting letters.
if key in ['displays', 'displaying', 'displayed']:
@ -114,16 +283,18 @@ def head_binary_search(key: str, words: List[str]) -> str:
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
show the word in the correct mood. """
show the word in the correct mood."""
name = "title-imperative-mood"
id = "Z1"
target = CommitMessageTitle
error_msg = ('The first word in commit title should be in imperative mood '
'("{word}" -> "{imperative}"): "{title}"')
error_msg = (
'The first word in commit title should be in imperative mood '
'("{word}" -> "{imperative}"): "{title}"'
)
def validate(self, line: str, commit: GitCommit) -> List[RuleViolation]:
violations = []
@ -134,11 +305,14 @@ class ImperativeMood(LineRule):
if first_word in WORD_SET:
imperative = head_binary_search(first_word, imperative_forms)
violation = RuleViolation(self.id, self.error_msg.format(
violation = RuleViolation(
self.id,
self.error_msg.format(
word=first_word,
imperative=imperative,
title=commit.message.title,
))
),
)
violations.append(violation)

View file

@ -12,6 +12,7 @@ EXCLUDED_FILES = [
'zulip/integrations/perforce/git_p4.py',
]
def run() -> None:
parser = argparse.ArgumentParser()
add_default_linter_arguments(parser)
@ -19,15 +20,23 @@ def run() -> None:
linter_config = LinterConfig(args)
by_lang = linter_config.list_files(file_types=['py', 'sh', 'json', 'md', 'txt'],
exclude=EXCLUDED_FILES)
by_lang = linter_config.list_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,
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.external_linter(
'mypy',
[sys.executable, 'tools/run-mypy'],
['py'],
pass_targets=False,
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
def custom_py() -> int:
@ -45,5 +54,6 @@ def run() -> None:
linter_config.do_lint()
if __name__ == '__main__':
run()

View file

@ -15,32 +15,39 @@ green = '\033[92m'
end_format = '\033[0m'
bold = '\033[1m'
def main():
usage = """./tools/provision
Creates a Python virtualenv. Its Python version is equal to
the Python version this command is executed with."""
parser = argparse.ArgumentParser(usage=usage)
parser.add_argument('--python-interpreter', '-p',
parser.add_argument(
'--python-interpreter',
'-p',
metavar='PATH_TO_PYTHON_INTERPRETER',
default=os.path.abspath(sys.executable),
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.')
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()
base_dir = os.path.abspath(os.path.join(__file__, '..', '..'))
py_version_output = subprocess.check_output([options.python_interpreter, '--version'],
stderr=subprocess.STDOUT, universal_newlines=True)
py_version_output = subprocess.check_output(
[options.python_interpreter, '--version'], stderr=subprocess.STDOUT, universal_newlines=True
)
# The output has the format "Python 1.2.3"
py_version_list = py_version_output.split()[1].split('.')
py_version = tuple(int(num) for num in py_version_list[0:2])
venv_name = 'zulip-api-py{}-venv'.format(py_version[0])
if py_version <= (3, 1) and (not options.force):
print(red + "Provision failed: Cannot create venv with outdated Python version ({}).\n"
"Maybe try `python3 tools/provision`."
.format(py_version_output.strip()) + end_format)
print(
red + "Provision failed: Cannot create venv with outdated Python version ({}).\n"
"Maybe try `python3 tools/provision`.".format(py_version_output.strip()) + end_format
)
sys.exit(1)
venv_dir = os.path.join(base_dir, venv_name)
@ -48,18 +55,24 @@ the Python version this command is executed with."""
try:
return_code = subprocess.call([options.python_interpreter, '-m', 'venv', venv_dir])
except OSError:
print("{red}Installation with venv failed. Probable errors are: "
print(
"{red}Installation with venv failed. Probable errors are: "
"You are on Ubuntu and you haven't installed python3-venv,"
"or you are running an unsupported python version"
"or python is not installed properly{end_format}"
.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)
raise
else:
# subprocess.call returns 0 if a script executed successfully
if return_code:
raise OSError("The command `{} -m venv {}` failed. Virtualenv not created!"
.format(options.python_interpreter, venv_dir))
raise OSError(
"The command `{} -m venv {}` failed. Virtualenv not created!".format(
options.python_interpreter, venv_dir
)
)
print("New virtualenv created.")
else:
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')
# We first install a modern version of pip that supports --prefix
subprocess.call([pip_path, 'install', 'pip>=9.0'])
if subprocess.call([pip_path, '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)))
if subprocess.call(
[
pip_path,
'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')
@ -105,10 +129,7 @@ the Python version this command is executed with."""
print(green + 'Success!' + end_format)
activate_command = os.path.join(base_dir,
venv_dir,
venv_exec_dir,
'activate')
activate_command = os.path.join(base_dir, venv_dir, venv_exec_dir, 'activate')
# We make the path look like a Unix path, because most Windows users
# are likely to be running in a bash shell.
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__)))
@contextmanager
def cd(newdir):
prevdir = os.getcwd()
@ -22,6 +23,7 @@ def cd(newdir):
finally:
os.chdir(prevdir)
def _generate_dist(dist_type, setup_file, package_name, setup_args):
message = 'Generating {dist_type} for {package_name}.'.format(
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))
def generate_bdist_wheel(setup_file, package_name, universal=False):
if universal:
_generate_dist('bdist_wheel', setup_file, package_name,
['bdist_wheel', '--universal'])
_generate_dist('bdist_wheel', setup_file, package_name, ['bdist_wheel', '--universal'])
else:
_generate_dist('bdist_wheel', setup_file, package_name,
['bdist_wheel'])
_generate_dist('bdist_wheel', setup_file, package_name, ['bdist_wheel'])
def twine_upload(dist_dirs):
message = 'Uploading distributions under the following directories:'
@ -55,14 +57,12 @@ def twine_upload(dist_dirs):
print(crayons.yellow(dist_dir))
twine.commands.upload.main(dist_dirs)
def cleanup(package_dir):
build_dir = os.path.join(package_dir, 'build')
temp_dir = os.path.join(package_dir, 'temp')
dist_dir = os.path.join(package_dir, 'dist')
egg_info = os.path.join(
package_dir,
'{}.egg-info'.format(os.path.basename(package_dir))
)
egg_info = os.path.join(package_dir, '{}.egg-info'.format(os.path.basename(package_dir)))
def _rm_if_it_exists(directory):
if os.path.isdir(directory):
@ -74,6 +74,7 @@ def cleanup(package_dir):
_rm_if_it_exists(dist_dir)
_rm_if_it_exists(egg_info)
def set_variable(fp, variable, value):
fh, temp_abs_path = tempfile.mkstemp()
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)
shutil.move(temp_abs_path, fp)
message = 'Set {variable} in {fp} to {value}.'.format(
fp=fp, variable=variable, value=value)
message = 'Set {variable} in {fp} to {value}.'.format(fp=fp, variable=variable, value=value)
print(crayons.white(message, bold=True))
def update_requirements_in_zulip_repo(zulip_repo_dir, version, hash_or_tag):
common = os.path.join(zulip_repo_dir, 'requirements', 'common.in')
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_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',
version=version)
zulip_line = url_zulip.format(tag=hash_or_tag, name='zulip',
version=version)
zulip_bots_line = url_zulip_bots.format(tag=hash_or_tag, name='zulip_bots', 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(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.'
print(crayons.white(message, bold=True))
def parse_args():
usage = """
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.add_argument('--cleanup', '-c',
parser.add_argument(
'--cleanup',
'-c',
action='store_true',
default=False,
help='Remove build directories (dist/, build/, egg-info/, etc).')
help='Remove build directories (dist/, build/, egg-info/, etc).',
)
parser.add_argument('--build', '-b',
parser.add_argument(
'--build',
'-b',
metavar='VERSION_NUM',
help=('Build sdists and wheels for all packages with the'
help=(
'Build sdists and wheels for all packages with the'
'specified version number.'
' sdists and wheels are stored in <package_name>/dist/*.'))
' sdists and wheels are stored in <package_name>/dist/*.'
),
)
parser.add_argument('--release', '-r',
parser.add_argument(
'--release',
'-r',
action='store_true',
default=False,
help='Upload the packages to PyPA using twine.')
help='Upload the packages to PyPA using twine.',
)
subparsers = parser.add_subparsers(dest='subcommand')
parser_main_repo = subparsers.add_parser(
'update-main-repo',
help='Update the zulip/requirements/* in the main zulip repo.'
'update-main-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('version', metavar='version number of the packages')
@ -203,6 +213,7 @@ And you're done! Congrats!
return parser.parse_args()
def main():
options = parse_args()
@ -239,11 +250,10 @@ def main():
if options.subcommand == 'update-main-repo':
if options.hash:
update_requirements_in_zulip_repo(options.repo, options.version,
options.hash)
update_requirements_in_zulip_repo(options.repo, options.version, options.hash)
else:
update_requirements_in_zulip_repo(options.repo, options.version,
options.version)
update_requirements_in_zulip_repo(options.repo, options.version, options.version)
if __name__ == '__main__':
main()

View file

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

View file

@ -99,38 +99,78 @@ force_include = [
"zulip_bots/zulip_bots/bots/front/front.py",
"zulip_bots/zulip_bots/bots/front/test_front.py",
"tools/custom_check.py",
"tools/deploy"
"tools/deploy",
]
parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.")
parser.add_argument('targets', nargs='*', default=[],
parser.add_argument(
'targets',
nargs='*',
default=[],
help="""files and directories to include in the result.
If this is not specified, the current directory is used""")
parser.add_argument('-m', '--modified', action='store_true', default=False, help='list only modified files')
parser.add_argument('-a', '--all', dest='all', action='store_true', default=False,
If this is not specified, the current directory is used""",
)
parser.add_argument(
'-m', '--modified', action='store_true', default=False, help='list only modified files'
)
parser.add_argument(
'-a',
'--all',
dest='all',
action='store_true',
default=False,
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""")
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()
if args.all:
exclude = []
# find all non-excluded files in current directory
files_dict = cast(Dict[str, List[str]],
lister.list_files(targets=args.targets, ftypes=['py', 'pyi'],
use_shebang=True, modified_only=args.modified,
exclude = exclude + ['stubs'], group_by_ftype=True,
extless_only=args.scripts_only))
files_dict = cast(
Dict[str, List[str]],
lister.list_files(
targets=args.targets,
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:
try:
@ -140,10 +180,13 @@ for inpath in force_include:
files_dict[ext].append(inpath)
pyi_files = set(files_dict['pyi'])
python_files = [fpath for fpath in files_dict['py']
if not fpath.endswith('.py') or fpath + 'i' not in pyi_files]
python_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:
repo = PurePath(file_path).parts[0]
if repo in repo_python_files:

View file

@ -1,4 +1,3 @@
import argparse
import os
import shutil
@ -10,21 +9,26 @@ import pytest
TOOLS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(os.path.dirname(TOOLS_DIR))
def handle_input_and_run_tests_for_package(package_name, path_list):
parser = argparse.ArgumentParser(description="Run tests for {}.".format(package_name))
parser.add_argument('--coverage',
parser.add_argument(
'--coverage',
nargs='?',
const=True,
default=False,
help='compute test coverage (--coverage combine to combine with previous reports)')
parser.add_argument('--pytest', '-p',
help='compute test coverage (--coverage combine to combine with previous reports)',
)
parser.add_argument(
'--pytest', '-p', default=False, action='store_true', help="run tests with pytest"
)
parser.add_argument(
'--verbose',
'-v',
default=False,
action='store_true',
help="run tests with pytest")
parser.add_argument('--verbose', '-v',
default=False,
action='store_true',
help='show verbose output (with pytest)')
help='show verbose output (with pytest)',
)
options = parser.parse_args()
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:
import coverage
cov = coverage.Coverage(config_file="tools/.coveragerc")
if options.coverage == 'combine':
cov.load()
@ -46,7 +51,7 @@ def handle_input_and_run_tests_for_package(package_name, path_list):
'-x', # stop on first test failure
'--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)
result = pytest.main(paths_to_test + pytest_options)
if result != 0:

View file

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

View file

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

View file

@ -11,9 +11,9 @@ import interrealm_bridge_config
import zulip
def create_pipe_event(to_client: zulip.Client, from_bot: Dict[str, Any],
to_bot: Dict[str, Any], stream_wide: bool
) -> Callable[[Dict[str, Any]], None]:
def create_pipe_event(
to_client: zulip.Client, from_bot: Dict[str, Any], to_bot: Dict[str, Any], stream_wide: bool
) -> Callable[[Dict[str, Any]], None]:
def _pipe_message(msg: Dict[str, Any]) -> None:
isa_stream = msg["type"] == "stream"
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"]:
# Fix the upload URL of the image to be the source of where it
# comes from
msg["content"] = msg["content"].replace("/user_uploads/",
from_bot["site"] + "/user_uploads/")
msg["content"] = msg["content"].replace(
"/user_uploads/", from_bot["site"] + "/user_uploads/"
)
if msg["content"].startswith(("```", "- ", "* ", "> ", "1. ")):
# If a message starts with special prefixes, make sure to prepend a newline for
# 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"]),
"has_attachment": msg.get("has_attachment", 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(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":
msg = event["message"]
_pipe_message(msg)
return _pipe_event
if __name__ == "__main__":
usage = """run-interrealm-bridge [--stream]
@ -71,20 +74,15 @@ if __name__ == "__main__":
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
parser = argparse.ArgumentParser(usage=usage)
parser.add_argument('--stream',
action='store_true',
help="",
default=False)
parser.add_argument('--stream', action='store_true', help="", default=False)
args = parser.parse_args()
options = interrealm_bridge_config.config
bot1 = options["bot_1"]
bot2 = options["bot_2"]
client1 = zulip.Client(email=bot1["email"], api_key=bot1["api_key"],
site=bot1["site"])
client2 = zulip.Client(email=bot2["email"], api_key=bot2["api_key"],
site=bot2["site"])
client1 = zulip.Client(email=bot1["email"], api_key=bot1["api_key"], site=bot1["site"])
client2 = zulip.Client(email=bot2["email"], api_key=bot2["api_key"], site=bot2["site"])
# A bidirectional tunnel
pipe_event1 = create_pipe_event(client2, bot1, bot2, args.stream)
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__":
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('--port', default=6667)
parser.add_argument('--nick-prefix', default=None)
@ -43,14 +45,24 @@ if __name__ == "__main__":
from irc_mirror_backend import IRCBot
except ImportError:
traceback.print_exc()
print("You have unsatisfied dependencies. Install all missing dependencies with "
"{} --provision".format(sys.argv[0]))
print(
"You have unsatisfied dependencies. Install all missing dependencies with "
"{} --provision".format(sys.argv[0])
)
sys.exit(1)
if options.irc_server is None or options.nick_prefix is None or options.channel is None:
parser.error("Missing required argument")
nickname = options.nick_prefix + "_zulip"
bot = IRCBot(zulip_client, options.stream, options.topic, options.channel,
nickname, options.irc_server, options.nickserv_pw, options.port)
bot = IRCBot(
zulip_client,
options.stream,
options.topic,
options.channel,
nickname,
options.irc_server,
options.nickserv_pw,
options.port,
)
bot.start()

View file

@ -10,8 +10,17 @@ from irc.client_aio import AioReactor
class IRCBot(irc.bot.SingleServerIRCBot):
reactor_class = AioReactor
def __init__(self, zulip_client: Any, stream: str, topic: str, channel: irc.bot.Channel,
nickname: str, server: str, nickserv_password: str = '', port: int = 6667) -> None:
def __init__(
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.zulip_client = zulip_client
self.stream = stream
@ -31,9 +40,7 @@ class IRCBot(irc.bot.SingleServerIRCBot):
# Taken from
# https://github.com/jaraco/irc/blob/master/irc/client_aio.py,
# in particular the method of AioSimpleIRCClient
self.c = self.reactor.loop.run_until_complete(
self.connection.connect(*args, **kwargs)
)
self.c = self.reactor.loop.run_until_complete(self.connection.connect(*args, **kwargs))
print("Listening now. Please send an IRC message to verify operation")
def check_subscription_or_die(self) -> None:
@ -43,7 +50,10 @@ class IRCBot(irc.bot.SingleServerIRCBot):
exit(1)
subs = [s["name"] for s in resp["subscriptions"]]
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)
def on_nicknameinuse(self, c: ServerConnection, e: Event) -> None:
@ -70,8 +80,11 @@ class IRCBot(irc.bot.SingleServerIRCBot):
else:
return
else:
recipients = [u["short_name"] for u in msg["display_recipient"] if
u["email"] != msg["sender_email"]]
recipients = [
u["short_name"]
for u in msg["display_recipient"]
if u["email"] != msg["sender_email"]
]
if len(recipients) == 1:
send = lambda x: self.c.privmsg(recipients[0], x)
else:
@ -89,12 +102,16 @@ class IRCBot(irc.bot.SingleServerIRCBot):
return
# Forward the PM to Zulip
print(self.zulip_client.send_message({
print(
self.zulip_client.send_message(
{
"sender": sender,
"type": "private",
"to": "username@example.com",
"content": content,
}))
}
)
)
def on_pubmsg(self, c: ServerConnection, e: Event) -> None:
content = e.arguments[0]
@ -103,12 +120,16 @@ class IRCBot(irc.bot.SingleServerIRCBot):
return
# Forward the stream message to Zulip
print(self.zulip_client.send_message({
print(
self.zulip_client.send_message(
{
"type": "stream",
"to": self.stream,
"subject": self.topic,
"content": "**{}**: {}".format(sender, content),
}))
}
)
)
def on_dccmsg(self, c: ServerConnection, e: Event) -> None:
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}"
MATRIX_MESSAGE_TEMPLATE = "<{username}> {message}"
class Bridge_ConfigException(Exception):
pass
class Bridge_FatalMatrixException(Exception):
pass
class Bridge_ZulipFatalException(Exception):
pass
def matrix_login(matrix_client: Any, matrix_config: Dict[str, Any]) -> None:
try:
matrix_client.login_with_password(matrix_config["username"],
matrix_config["password"])
matrix_client.login_with_password(matrix_config["username"], matrix_config["password"])
except MatrixRequestError as exception:
if exception.code == 403:
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:
raise Bridge_FatalMatrixException("Bad URL format.")
def matrix_join_room(matrix_client: Any, matrix_config: Dict[str, Any]) -> Any:
try:
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:
raise Bridge_FatalMatrixException("Couldn't find room.")
def die(signal: int, frame: FrameType) -> None:
# We actually want to exit, so run os._exit (so as not to be caught and restarted)
os._exit(1)
def matrix_to_zulip(
zulip_client: zulip.Client,
zulip_config: Dict[str, Any],
@ -78,12 +84,14 @@ def matrix_to_zulip(
if not_from_zulip_bot and content:
try:
result = zulip_client.send_message({
result = zulip_client.send_message(
{
"type": "stream",
"to": zulip_config["stream"],
"subject": zulip_config["topic"],
"content": content,
})
}
)
except Exception as exception: # XXX This should be more specific
# Generally raised when user is forbidden
raise Bridge_ZulipFatalException(exception)
@ -93,6 +101,7 @@ def matrix_to_zulip(
return _matrix_to_zulip
def get_message_content_from_event(event: Dict[str, Any], no_noise: bool) -> Optional[str]:
irc_nick = shorten_irc_nick(event['sender'])
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.
# To enable these events pass `no_noise` as `False` as the script argument
if event['membership'] == "join":
content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick,
message="joined")
content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, message="joined")
elif event['membership'] == "leave":
content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick,
message="quit")
content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, message="quit")
elif event['type'] == "m.room.message":
if event['content']['msgtype'] == "m.text" or event['content']['msgtype'] == "m.emote":
content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick,
message=event['content']['body'])
content = ZULIP_MESSAGE_TEMPLATE.format(
username=irc_nick, message=event['content']['body']
)
else:
content = event['type']
return content
def shorten_irc_nick(nick: str) -> str:
"""
Add nick shortner functions for specific IRC networks
@ -130,8 +139,8 @@ def shorten_irc_nick(nick: str) -> str:
return match.group(1)
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:
"""
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)
if message_valid:
matrix_username = msg["sender_full_name"].replace(' ', '')
matrix_text = MATRIX_MESSAGE_TEMPLATE.format(username=matrix_username,
message=msg["content"])
matrix_text = MATRIX_MESSAGE_TEMPLATE.format(
username=matrix_username, message=msg["content"]
)
# Forward Zulip message to Matrix
room.send_text(matrix_text)
return _zulip_to_matrix
def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> bool:
is_a_stream = msg["type"] == "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 False
def generate_parser() -> argparse.ArgumentParser:
description = """
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)
* #freenode_#zulip:matrix.org (zulip channel on irc.freenode.net)"""
parser = argparse.ArgumentParser(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('--write-sample-config', metavar='PATH', dest='sample_config',
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.")
parser = argparse.ArgumentParser(
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(
'--write-sample-config',
metavar='PATH',
dest='sample_config',
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
def read_configuration(config_file: str) -> Dict[str, Dict[str, str]]:
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()}
def write_sample_config(target_path: str, zuliprc: Optional[str]) -> None:
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((
('matrix', OrderedDict((
sample_dict = OrderedDict(
(
(
'matrix',
OrderedDict(
(
('host', 'https://matrix.org'),
('username', 'username'),
('password', 'password'),
('room_id', '#zulip:matrix.org'),
))),
('zulip', OrderedDict((
)
),
),
(
'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 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:
sample.write(target)
def main() -> None:
signal.signal(signal.SIGINT, die)
logging.basicConfig(level=logging.WARNING)
@ -254,8 +298,11 @@ def main() -> None:
if options.zuliprc is None:
print("Wrote sample configuration to '{}'".format(options.sample_config))
else:
print("Wrote sample configuration to '{}' using zuliprc file '{}'"
.format(options.sample_config, options.zuliprc))
print(
"Wrote sample configuration to '{}' using zuliprc file '{}'".format(
options.sample_config, options.zuliprc
)
)
sys.exit(0)
elif not options.config:
print("Options required: -c or --config to run, OR --write-sample-config.")
@ -277,9 +324,11 @@ def main() -> None:
while backoff.keep_going():
print("Starting matrix mirroring bot")
try:
zulip_client = zulip.Client(email=zulip_config["email"],
zulip_client = zulip.Client(
email=zulip_config["email"],
api_key=zulip_config["api_key"],
site=zulip_config["site"])
site=zulip_config["site"],
)
matrix_client = MatrixClient(matrix_config["host"])
# Login to Matrix
@ -287,8 +336,9 @@ def main() -> None:
# Join a room in Matrix
room = matrix_join_room(matrix_client, matrix_config)
room.add_listener(matrix_to_zulip(zulip_client, zulip_config, matrix_config,
options.no_noise))
room.add_listener(
matrix_to_zulip(zulip_client, zulip_config, matrix_config, options.no_noise)
)
print("Starting listener thread on Matrix client")
matrix_client.start_listener_thread()
@ -306,5 +356,6 @@ def main() -> None:
traceback.print_exc()
backoff.fail()
if __name__ == '__main__':
main()

View file

@ -30,22 +30,26 @@ topic = matrix
"""
@contextmanager
def new_temp_dir() -> Iterator[str]:
path = mkdtemp()
yield path
shutil.rmtree(path)
class MatrixBridgeScriptTests(TestCase):
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")
def test_no_args(self) -> None:
output_lines = self.output_from_script([])
expected_lines = [
"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):
self.assertIn(expected, output)
@ -74,19 +78,27 @@ class MatrixBridgeScriptTests(TestCase):
def test_write_sample_config_from_zuliprc(self) -> None:
zuliprc_template = ["[api]", "email={email}", "key={key}", "site={site}"]
zulip_params = {'email': 'foo@bar',
zulip_params = {
'email': 'foo@bar',
'key': 'some_api_key',
'site': 'https://some.chat.serverplace'}
'site': 'https://some.chat.serverplace',
}
with new_temp_dir() as tempdir:
path = os.path.join(tempdir, sample_config_path)
zuliprc_path = os.path.join(tempdir, "zuliprc")
with open(zuliprc_path, "w") as zuliprc_file:
zuliprc_file.write("\n".join(zuliprc_template).format(**zulip_params))
output_lines = self.output_from_script(["--write-sample-config", path,
"--from-zuliprc", zuliprc_path])
self.assertEqual(output_lines,
["Wrote sample configuration to '{}' using zuliprc file '{}'"
.format(path, zuliprc_path)])
output_lines = self.output_from_script(
["--write-sample-config", path, "--from-zuliprc", zuliprc_path]
)
self.assertEqual(
output_lines,
[
"Wrote sample configuration to '{}' using zuliprc file '{}'".format(
path, zuliprc_path
)
],
)
with open(path) as sample_file:
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)
zuliprc_path = os.path.join(tempdir, "zuliprc")
# No writing of zuliprc file here -> triggers check for zuliprc absence
output_lines = self.output_from_script(["--write-sample-config", path,
"--from-zuliprc", zuliprc_path])
self.assertEqual(output_lines,
["Could not write sample config: Zuliprc file '{}' does not exist."
.format(zuliprc_path)])
output_lines = self.output_from_script(
["--write-sample-config", path, "--from-zuliprc", zuliprc_path]
)
self.assertEqual(
output_lines,
[
"Could not write sample config: Zuliprc file '{}' does not exist.".format(
zuliprc_path
)
],
)
class MatrixBridgeZulipToMatrixTests(TestCase):
valid_zulip_config = dict(
stream="some stream",
topic="some topic",
email="some@email"
)
valid_zulip_config = dict(stream="some stream", topic="some topic", email="some@email")
valid_msg = dict(
sender_email="John@Smith.smith", # must not be equal to config:email
type="stream", # Can only mirror Zulip streams
display_recipient=valid_zulip_config['stream'],
subject=valid_zulip_config['topic']
subject=valid_zulip_config['topic'],
)
def test_zulip_message_validity_success(self) -> None:

View file

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

View file

@ -18,6 +18,7 @@ import zulip
ZULIP_MESSAGE_TEMPLATE = "**{username}**: {message}"
SLACK_MESSAGE_TEMPLATE = "<{username}> {message}"
def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> bool:
is_a_stream = msg["type"] == "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 False
class SlackBridge:
def __init__(self, config: Dict[str, Any]) -> None:
self.config = config
@ -40,7 +42,8 @@ class SlackBridge:
self.zulip_client = zulip.Client(
email=self.zulip_config["email"],
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_subject = self.zulip_config["topic"]
@ -69,18 +72,22 @@ class SlackBridge:
message_valid = check_zulip_message_validity(msg, self.zulip_config)
if message_valid:
self.wrap_slack_mention_with_bracket(msg)
slack_text = SLACK_MESSAGE_TEMPLATE.format(username=msg["sender_full_name"],
message=msg["content"])
slack_text = SLACK_MESSAGE_TEMPLATE.format(
username=msg["sender_full_name"], message=msg["content"]
)
self.slack_webclient.chat_postMessage(
channel=self.channel,
text=slack_text,
)
return _zulip_to_slack
def run_slack_listener(self) -> None:
members = self.slack_webclient.users_list()['members']
# 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()}
@RTMClient.run_on(event='message')
@ -96,14 +103,13 @@ class SlackBridge:
self.replace_slack_id_with_name(msg)
content = ZULIP_MESSAGE_TEMPLATE.format(username=user, message=msg['text'])
msg_data = dict(
type="stream",
to=self.zulip_stream,
subject=self.zulip_subject,
content=content)
type="stream", to=self.zulip_stream, subject=self.zulip_subject, content=content
)
self.zulip_client.send_message(msg_data)
self.slack_client.start()
if __name__ == "__main__":
usage = """run-slack-bridge
@ -124,7 +130,9 @@ if __name__ == "__main__":
try:
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=())
print("Starting message handler on Zulip client")
zp.start()

View file

@ -39,7 +39,8 @@ client = zulip.Client(
email=config.ZULIP_USER,
site=config.ZULIP_SITE,
api_key=config.ZULIP_API_KEY,
client="ZulipCodebase/" + VERSION)
client="ZulipCodebase/" + VERSION,
)
user_agent = "Codebase To Zulip Mirroring script (zulip-devel@googlegroups.com)"
# find some form of JSON loader/dumper, with a preference order for speed.
@ -52,13 +53,18 @@ while len(json_implementations):
except ImportError:
continue
def make_api_call(path: str) -> Optional[List[Dict[str, Any]]]:
response = requests.get("https://api3.codebasehq.com/%s" % (path,),
response = requests.get(
"https://api3.codebasehq.com/%s" % (path,),
auth=(config.CODEBASE_API_USERNAME, config.CODEBASE_API_KEY),
params={'raw': 'True'},
headers = {"User-Agent": user_agent,
headers={
"User-Agent": user_agent,
"Content-Type": "application/json",
"Accept": "application/json"})
"Accept": "application/json",
},
)
if response.status_code == 200:
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")
sys.exit(-1)
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
def make_url(path: str) -> str:
return "%s/%s" % (config.CODEBASE_ROOT_URL, path)
def handle_event(event: Dict[str, Any]) -> None:
event = event['event']
event_type = event['type']
@ -114,11 +124,17 @@ def handle_event(event: Dict[str, Any]) -> None:
else:
if new_ref:
branch = "new branch %s" % (branch,)
content = ("%s pushed %s commit(s) to %s in project %s:\n\n" %
(actor_name, num_commits, branch, project))
content = "%s pushed %s commit(s) to %s in project %s:\n\n" % (
actor_name,
num_commits,
branch,
project,
)
for commit in raw_props.get('commits'):
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')
content += "* [%s](%s): %s\n" % (ref, url, message)
elif event_type == 'ticketing_ticket':
@ -133,8 +149,10 @@ def handle_event(event: Dict[str, Any]) -> None:
if assignee is None:
assignee = "no one"
subject = "#%s: %s" % (num, name)
content = ("""%s created a new ticket [#%s](%s) priority **%s** assigned to %s:\n\n~~~ quote\n %s""" %
(actor_name, num, url, priority, assignee, name))
content = (
"""%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':
stream = config.ZULIP_TICKETS_STREAM_NAME
@ -148,11 +166,19 @@ def handle_event(event: Dict[str, Any]) -> None:
content = ""
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:
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':
stream = config.ZULIP_TICKETS_STREAM_NAME
@ -172,10 +198,17 @@ def handle_event(event: Dict[str, Any]) -> None:
if commit:
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)
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:
# Otherwise, this is a Discussion item, and handle it
subj = raw_props.get("subject")
@ -199,17 +232,32 @@ def handle_event(event: Dict[str, Any]) -> None:
servers = raw_props.get('servers')
repo_link = raw_props.get('repository_permalink')
start_ref_url = make_url("projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, start_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))
start_ref_url = make_url(
"projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, start_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,)
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))
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,
)
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':
# 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:
subject = subject[:57].rstrip() + '...'
res = client.send_message({"type": "stream",
"to": stream,
"subject": subject,
"content": content})
res = client.send_message(
{"type": "stream", "to": stream, "subject": subject, "content": content}
)
if res['result'] == 'success':
logging.info("Successfully sent Zulip with id: %s" % (res['id'],))
else:
@ -278,6 +325,7 @@ def run_mirror() -> None:
open(config.RESUME_FILE, 'w').write(since.strftime("%s"))
logging.info("Shutting down Codebase mirror")
# void function that checks the permissions of the files this script needs.
def check_permissions() -> None:
# check that the log file can be written
@ -291,9 +339,12 @@ def check_permissions() -> None:
try:
open(config.RESUME_FILE, "a+")
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))
if __name__ == "__main__":
assert isinstance(config.RESUME_FILE, str), "RESUME_FILE path not given; refusing to continue"
check_permissions()

View file

@ -29,18 +29,20 @@ client = zulip.Client(
email=config.ZULIP_USER,
site=config.ZULIP_SITE,
api_key=config.ZULIP_API_KEY,
client="ZulipGit/" + VERSION)
client="ZulipGit/" + VERSION,
)
def git_repository_name() -> Text:
output = subprocess.check_output(["git", "rev-parse", "--is-bare-repository"])
if output.strip() == "true":
return os.path.basename(os.getcwd())[:-len(".git")]
return os.path.basename(os.getcwd())[: -len(".git")]
else:
return os.path.basename(os.path.dirname(os.getcwd()))
def git_commit_range(oldrev: str, newrev: str) -> str:
log_cmd = ["git", "log", "--reverse",
"--pretty=%aE %H %s", "%s..%s" % (oldrev, newrev)]
log_cmd = ["git", "log", "--reverse", "--pretty=%aE %H %s", "%s..%s" % (oldrev, newrev)]
commits = ''
for ln in subprocess.check_output(log_cmd, universal_newlines=True).splitlines():
author_email, commit_id, subject = ln.split(None, 2)
@ -50,6 +52,7 @@ def git_commit_range(oldrev: str, newrev: str) -> str:
commits += '!avatar(%s) %s\n' % (author_email, subject)
return commits
def send_bot_message(oldrev: str, newrev: str, refname: str) -> None:
repo_name = git_repository_name()
branch = refname.replace('refs/heads/', '')
@ -95,6 +98,7 @@ def send_bot_message(oldrev: str, newrev: str, refname: str) -> None:
}
client.send_message(message_data)
for ln in sys.stdin:
oldrev, newrev, refname = ln.strip().split()
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).
def commit_notice_destination(repo: Text, branch: Text, commit: Text) -> Optional[Dict[Text, Text]]:
if branch in ["master", "test-post-receive"]:
return dict(stream = STREAM_NAME,
subject = "%s" % (branch,))
return dict(stream=STREAM_NAME, subject="%s" % (branch,))
# Return None for cases where you don't want a notice sent
return None
# Modify this function to change how commits are displayed; the most
# common customization is to include a link to the commit in your
# 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:
return '!avatar(%s) %s\n' % (author, subject)
## If properly installed, the Zulip API should be in your import
## path, but if not, set a custom path below
ZULIP_API_PATH: Optional[str] = None

View file

@ -7,7 +7,10 @@ from oauth2client.file import Storage
try:
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:
flags = None
@ -22,6 +25,7 @@ CLIENT_SECRET_FILE = 'client_secret.json'
APPLICATION_NAME = 'Zulip Calendar Bot'
HOME_DIR = os.path.expanduser('~')
def get_credentials() -> client.Credentials:
"""Gets valid user credentials from storage.
@ -32,8 +36,7 @@ def get_credentials() -> client.Credentials:
Credentials, the obtained credential.
"""
credential_path = os.path.join(HOME_DIR,
'google-credentials.json')
credential_path = os.path.join(HOME_DIR, 'google-credentials.json')
store = Storage(credential_path)
credentials = store.get()
@ -49,4 +52,5 @@ def get_credentials() -> client.Credentials:
credentials = tools.run(flow, store)
print('Storing credentials to ' + credential_path)
get_credentials()

View file

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

View file

@ -15,7 +15,10 @@ import zulip
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
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:
shortlog_base_url = web_url.rstrip("/") + "/shortlog/"
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(
revcount=revcount, s=plural, url=summary_url)
revcount=revcount, s=plural, url=summary_url
)
else:
formatted_commit_count = "{revcount} commit{s}".format(
revcount=revcount, s=plural)
formatted_commit_count = "{revcount} commit{s}".format(revcount=revcount, s=plural)
return "**{user}** pushed {commits} to **{branch}** (`{tip}:{node}`):\n\n".format(
user=user, commits=formatted_commit_count, branch=branch, tip=tip,
node=node[:12])
user=user, commits=formatted_commit_count, branch=branch, tip=tip, node=node[:12]
)
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:
summary_url = rev_base_url + str(rev_ctx)
summary = "* [{summary}]({url})".format(
summary=one_liner, url=summary_url)
summary = "* [{summary}]({url})".format(summary=one_liner, url=summary_url)
else:
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)
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
a bot in most cases.
"""
client = zulip.Client(email=email, api_key=api_key,
site=site,
client="ZulipMercurial/" + VERSION)
client = zulip.Client(
email=email, api_key=api_key, site=site, client="ZulipMercurial/" + VERSION
)
message_data = {
"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)
def get_config(ui: ui, item: str) -> str:
try:
# 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))
sys.exit(1)
def hook(ui: ui, repo: repo, **kwargs: Text) -> None:
"""
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)"""
os._exit(1)
signal.signal(signal.SIGINT, die)
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"
def room_to_stream(room: str) -> str:
return room + "/xmpp"
def stream_to_room(stream: str) -> str:
return stream.lower().rpartition("/xmpp")[0]
def jid_to_zulip(jid: JID) -> str:
suffix = ''
if not jid.username.endswith("-bot"):
suffix = options.zulip_email_suffix
return "%s%s@%s" % (jid.username, suffix, options.zulip_domain)
def zulip_to_jid(email: str, jabber_domain: str) -> JID:
jid = JID(email, domain=jabber_domain)
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]
return jid
class JabberToZulipBot(ClientXMPP):
def __init__(self, jid: JID, password: str, rooms: List[str]) -> None:
if jid.resource:
@ -153,10 +158,10 @@ class JabberToZulipBot(ClientXMPP):
recipient = jid_to_zulip(msg["to"])
zulip_message = dict(
sender = sender,
type = "private",
to = recipient,
content = msg["body"],
sender=sender,
type="private",
to=recipient,
content=msg["body"],
)
ret = self.zulipToJabber.client.send_message(zulip_message)
if ret.get("result") != "success":
@ -178,12 +183,12 @@ class JabberToZulipBot(ClientXMPP):
jid = self.nickname_to_jid(msg.get_mucroom(), sender_nick)
sender = jid_to_zulip(jid)
zulip_message = dict(
forged = "yes",
sender = sender,
type = "stream",
subject = subject,
to = stream,
content = msg["body"],
forged="yes",
sender=sender,
type="stream",
subject=subject,
to=stream,
content=msg["body"],
)
ret = self.zulipToJabber.client.send_message(zulip_message)
if ret.get("result") != "success":
@ -191,11 +196,12 @@ class JabberToZulipBot(ClientXMPP):
def nickname_to_jid(self, room: str, nick: str) -> 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)
else:
return jid
class ZulipToJabberBot:
def __init__(self, zulip_client: Client) -> None:
self.client = zulip_client
@ -221,7 +227,7 @@ class ZulipToJabberBot:
self.process_subscription(event)
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']
if not stream.endswith("/xmpp"):
return
@ -229,14 +235,13 @@ class ZulipToJabberBot:
room = stream_to_room(stream)
jabber_recipient = JID(local=room, domain=options.conference_domain)
outgoing = self.jabber.make_message(
mto = jabber_recipient,
mbody = msg['content'],
mtype = 'groupchat')
mto=jabber_recipient, mbody=msg['content'], mtype='groupchat'
)
outgoing['thread'] = '\u1FFFE'
outgoing.send()
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']:
if recipient["email"] == self.client.email:
continue
@ -245,14 +250,13 @@ class ZulipToJabberBot:
recip_email = recipient['email']
jabber_recipient = zulip_to_jid(recip_email, self.jabber.boundjid.domain)
outgoing = self.jabber.make_message(
mto = jabber_recipient,
mbody = msg['content'],
mtype = 'chat')
mto=jabber_recipient, mbody=msg['content'], mtype='chat'
)
outgoing['thread'] = '\u1FFFE'
outgoing.send()
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':
streams = [s['name'].lower() for s in event['subscriptions']]
streams = [s for s in streams if s.endswith("/xmpp")]
@ -264,6 +268,7 @@ class ZulipToJabberBot:
for stream in streams:
self.jabber.leave_muc(stream_to_room(stream))
def get_rooms(zulipToJabber: ZulipToJabberBot) -> List[str]:
def get_stream_infos(key: str, method: Callable[[], Dict[str, Any]]) -> Any:
ret = method()
@ -284,17 +289,21 @@ def get_rooms(zulipToJabber: ZulipToJabberBot) -> List[str]:
rooms.append(stream_to_room(stream))
return rooms
def config_error(msg: str) -> None:
sys.stderr.write("%s\n" % (msg,))
sys.exit(2)
if __name__ == '__main__':
parser = optparse.OptionParser(
epilog='''Most general and Jabber configuration options may also be specified in the
zulip configuration file under the jabber_mirror section (exceptions are noted
in their help sections). Keys have the same name as options with hyphens
replaced with underscores. Zulip configuration options go in the api section,
as normal.'''.replace("\n", " ")
as normal.'''.replace(
"\n", " "
)
)
parser.add_option(
'--mode',
@ -305,7 +314,10 @@ as normal.'''.replace("\n", " ")
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
user and mirrors messages sent to Jabber rooms to Zulip. Defaults to
"personal"'''.replace("\n", " "))
"personal"'''.replace(
"\n", " "
),
)
parser.add_option(
'--zulip-email-suffix',
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
"+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
option does not affect login credentials.'''.replace("\n", " "))
parser.add_option('-d', '--debug',
option does not affect login credentials.'''.replace(
"\n", " "
),
)
parser.add_option(
'-d',
'--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)
default=logging.INFO,
)
jabber_group = optparse.OptionGroup(parser, "Jabber configuration")
jabber_group.add_option(
@ -332,36 +350,39 @@ option does not affect login credentials.'''.replace("\n", " "))
"it will be used as the nickname when joining MUCs. "
"Specifying the nickname is mostly useful if you want "
"to run the public mirror from a regular user instead of "
"from a dedicated account.")
jabber_group.add_option('--jabber-password',
default=None,
action='store',
help="Your Jabber password")
jabber_group.add_option('--conference-domain',
"from a dedicated account.",
)
jabber_group.add_option(
'--jabber-password', default=None, action='store', help="Your Jabber password"
)
jabber_group.add_option(
'--conference-domain',
default=None,
action='store',
help="Your Jabber conference domain (E.g. conference.jabber.example.com). "
"If not specifed, \"conference.\" will be prepended to your JID's domain.")
jabber_group.add_option('--no-use-tls',
default=None,
action='store_true')
jabber_group.add_option('--jabber-server-address',
"If not specifed, \"conference.\" will be prepended to your JID's domain.",
)
jabber_group.add_option('--no-use-tls', default=None, action='store_true')
jabber_group.add_option(
'--jabber-server-address',
default=None,
action='store',
help="The hostname of your Jabber server. This is only needed if "
"your server is missing SRV records")
jabber_group.add_option('--jabber-server-port',
"your server is missing SRV records",
)
jabber_group.add_option(
'--jabber-server-port',
default='5222',
action='store',
help="The port of your Jabber server. This is only needed if "
"your server is missing SRV records")
"your server is missing SRV records",
)
parser.add_option_group(jabber_group)
parser.add_option_group(zulip.generate_option_group(parser, "zulip-"))
(options, args) = parser.parse_args()
logging.basicConfig(level=options.log_level,
format='%(levelname)-8s %(message)s')
logging.basicConfig(level=options.log_level, format='%(levelname)-8s %(message)s')
if options.zulip_config_file is None:
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)
except OSError:
pass
for option in ("jid", "jabber_password", "conference_domain", "mode", "zulip_email_suffix",
"jabber_server_address", "jabber_server_port"):
if (
getattr(options, option) is None
and config.has_option("jabber_mirror", option)
for option in (
"jid",
"jabber_password",
"conference_domain",
"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))
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'")
if None in (options.jid, options.jabber_password):
config_error("You must specify your Jabber JID and Jabber password either "
"in the Zulip configuration file or on the commandline")
config_error(
"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
options.zulip_domain = zulipToJabber.client.email.partition('@')[-1]
@ -438,8 +467,9 @@ option does not affect login credentials.'''.replace("\n", " "))
try:
logging.info("Connecting to Zulip.")
zulipToJabber.client.call_on_each_event(zulipToJabber.process_event,
event_types=event_types)
zulipToJabber.client.call_on_each_event(
zulipToJabber.process_event, event_types=event_types
)
except BaseException:
logging.exception("Exception in main loop")
xmpp.abort()

View file

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

View file

@ -21,7 +21,9 @@ client = zulip.Client(
email=config.ZULIP_USER,
site=config.ZULIP_SITE,
api_key=config.ZULIP_API_KEY,
client='ZulipOpenShift/' + VERSION)
client='ZulipOpenShift/' + VERSION,
)
def get_deployment_details() -> Dict[str, str]:
# "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]
splits = dep.split(' - ')
return dict(app_name=os.environ['OPENSHIFT_APP_NAME'],
return dict(
app_name=os.environ['OPENSHIFT_APP_NAME'],
url=os.environ['OPENSHIFT_APP_DNS'],
branch=splits[2],
commit_id=splits[3])
commit_id=splits[3],
)
def send_bot_message(deployment: Dict[str, str]) -> None:
destination = config.deployment_notice_destination(deployment['branch'])
@ -42,14 +47,17 @@ def send_bot_message(deployment: Dict[str, str]) -> None:
return
message = config.format_deployment_message(**deployment)
client.send_message({
client.send_message(
{
'type': 'stream',
'to': destination['stream'],
'subject': destination['subject'],
'content': message,
})
}
)
return
deployment = get_deployment_details()
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).
def deployment_notice_destination(branch: str) -> Optional[Dict[str, Text]]:
if branch in ['master', 'test-post-receive']:
return dict(stream = 'deployments',
subject = '%s' % (branch,))
return dict(stream='deployments', subject='%s' % (branch,))
# Return None for cases where you don't want a notice sent
return None
# Modify this function to change how deployments are displayed
#
# It takes the following arguments:
@ -39,9 +39,15 @@ def deployment_notice_destination(branch: str) -> Optional[Dict[str, Text]]:
# * dep_id = deployment id
# * dep_time = deployment timestamp
def format_deployment_message(
app_name: str = '', url: str = '', 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)
app_name: str = '',
url: str = '',
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
## path, but if not, set a custom path below

View file

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

View file

@ -37,12 +37,12 @@ def commit_notice_destination(path: Text, changelist: int) -> Optional[Dict[Text
directory = dirs[2]
if directory not in ["evil-master-plan", "my-super-secret-repository"]:
return dict(stream = "%s-commits" % (directory,),
subject = path)
return dict(stream="%s-commits" % (directory,), subject=path)
# Return None for cases where you don't want a notice sent
return None
## If properly installed, the Zulip API should be in your import
## path, but if not, set a custom path below
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"""
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage)) # type: argparse.ArgumentParser
parser.add_argument('--stream',
parser = zulip.add_default_arguments(
argparse.ArgumentParser(usage)
) # type: argparse.ArgumentParser
parser.add_argument(
'--stream',
dest='stream',
help='The stream to which to send RSS messages.',
default="rss",
action='store')
parser.add_argument('--data-dir',
action='store',
)
parser.add_argument(
'--data-dir',
dest='data_dir',
help='The directory where feed metadata is stored',
default=os.path.join(RSS_DATA_DIR),
action='store')
parser.add_argument('--feed-file',
action='store',
)
parser.add_argument(
'--feed-file',
dest='feed_file',
help='The file containing a list of RSS feed URLs to follow, one URL per line',
default=os.path.join(RSS_DATA_DIR, "rss-feeds"),
action='store')
parser.add_argument('--unwrap',
action='store',
)
parser.add_argument(
'--unwrap',
dest='unwrap',
action='store_true',
help='Convert word-wrapped paragraphs into single lines',
default=False)
parser.add_argument('--math',
default=False,
)
parser.add_argument(
'--math',
dest='math',
action='store_true',
help='Convert $ to $$ (for KaTeX processing)',
default=False)
default=False,
)
opts = parser.parse_args() # type: Any
def mkdir_p(path: str) -> None:
# Python doesn't have an analog to `mkdir -p` < Python 3.2.
try:
@ -87,6 +100,7 @@ def mkdir_p(path: str) -> None:
else:
raise
try:
mkdir_p(opts.data_dir)
except OSError:
@ -106,11 +120,13 @@ logger = logging.getLogger(__name__) # type: logging.Logger
logger.setLevel(logging.DEBUG)
logger.addHandler(file_handler)
def log_error_and_exit(error: str) -> None:
logger.error(error)
logger.error(usage)
exit(1)
class MLStripper(HTMLParser):
def __init__(self) -> None:
super().__init__()
@ -123,41 +139,49 @@ class MLStripper(HTMLParser):
def get_data(self) -> str:
return ''.join(self.fed)
def strip_tags(html: str) -> str:
stripper = MLStripper()
stripper.feed(html)
return stripper.get_data()
def compute_entry_hash(entry: Dict[str, Any]) -> str:
entry_time = entry.get("published", entry.get("updated"))
entry_id = entry.get("id", entry.get("link"))
return hashlib.md5((entry_id + str(entry_time)).encode()).hexdigest()
def unwrap_text(body: str) -> str:
# 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'
return re.sub('(?<=[^\n])\n(?=[^\n])', ' ', body)
def elide_subject(subject: str) -> str:
MAX_TOPIC_LENGTH = 60
if len(subject) > MAX_TOPIC_LENGTH:
subject = subject[:MAX_TOPIC_LENGTH - 3].rstrip() + '...'
subject = subject[: MAX_TOPIC_LENGTH - 3].rstrip() + '...'
return subject
def send_zulip(entry: Any, feed_name: str) -> Dict[str, Any]:
body = entry.summary # type: str
if opts.unwrap:
body = unwrap_text(body)
content = "**[%s](%s)**\n%s\n%s" % (entry.title,
content = "**[%s](%s)**\n%s\n%s" % (
entry.title,
entry.link,
strip_tags(body),
entry.link) # type: str
entry.link,
) # type: str
if opts.math:
content = content.replace('$', '$$')
message = {"type": "stream",
message = {
"type": "stream",
"sender": opts.zulip_email,
"to": opts.stream,
"subject": elide_subject(feed_name),
@ -165,15 +189,20 @@ def send_zulip(entry: Any, feed_name: str) -> Dict[str, Any]:
} # type: Dict[str, str]
return client.send_message(message)
try:
with open(opts.feed_file) as f:
feed_urls = [feed.strip() for feed in f.readlines()] # type: List[str]
except OSError:
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(
email=opts.zulip_email,
api_key=opts.zulip_api_key,
config_file=opts.zulip_config_file,
site=opts.zulip_site, client="ZulipRSS/" + VERSION) # type: zulip.Client
site=opts.zulip_site,
client="ZulipRSS/" + VERSION,
) # type: zulip.Client
first_message = True # type: bool
@ -182,7 +211,9 @@ for feed_url in feed_urls:
try:
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:
old_feed_hashes = {}
@ -192,8 +223,13 @@ for feed_url in feed_urls:
for entry in data.entries:
entry_hash = compute_entry_hash(entry) # type: str
# An entry has either been published or updated.
entry_time = entry.get("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:
entry_time = entry.get(
"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
# entries older than some threshold.
continue

View file

@ -30,7 +30,8 @@ client = zulip.Client(
email=config.ZULIP_USER,
site=config.ZULIP_SITE,
api_key=config.ZULIP_API_KEY,
client="ZulipSVN/" + VERSION) # type: zulip.Client
client="ZulipSVN/" + VERSION,
) # type: zulip.Client
svn = pysvn.Client() # type: pysvn.Client
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://"
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(
entry['author'],
rev,
path.split('/')[-1],
entry['revprops']['svn:log']) # type: Text
entry['author'], rev, path.split('/')[-1], entry['revprops']['svn:log']
) # type: 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]]:
repo = path.split('/')[-1]
if repo not in ["evil-master-plan", "my-super-secret-repository"]:
return dict(stream = "commits",
subject = "%s" % (repo,))
return dict(stream="commits", subject="%s" % (repo,))
# Return None for cases where you don't want a notice sent
return None
## If properly installed, the Zulip API should be in your import
## path, but if not, set a custom path below
ZULIP_API_PATH: Optional[str] = None

View file

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

View file

@ -13,6 +13,7 @@ except ImportError:
print("http://docs.python-requests.org/en/master/user/install/")
sys.exit(1)
def get_model_id(options):
"""get_model_id
@ -24,19 +25,14 @@ def get_model_id(options):
"""
trello_api_url = 'https://api.trello.com/1/board/{}'.format(
options.trello_board_id
)
trello_api_url = 'https://api.trello.com/1/board/{}'.format(options.trello_board_id)
params = {
'key': options.trello_api_key,
'token': options.trello_token,
}
trello_response = requests.get(
trello_api_url,
params=params
)
trello_response = requests.get(trello_api_url, params=params)
if trello_response.status_code != 200:
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,
),
'callbackURL': options.zulip_webhook_url,
'idModel': id_model
'idModel': id_model,
}
trello_response = requests.post(
trello_api_url,
data=data
)
trello_response = requests.post(trello_api_url, data=data)
if trello_response.status_code != 200:
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']
def create_webhook(options):
"""create_webhook
@ -106,8 +100,12 @@ def create_webhook(options):
if id_webhook:
print('Success! The webhook ID is', id_webhook)
print('Success! The webhook for the {} Trello board was successfully created.'.format(
options.trello_board_name))
print(
'Success! The webhook for the {} Trello board was successfully created.'.format(
options.trello_board_name
)
)
def main():
description = """
@ -120,28 +118,36 @@ at <https://zulip.com/integrations/doc/trello>.
"""
parser = argparse.ArgumentParser(description=description)
parser.add_argument('--trello-board-name',
parser.add_argument('--trello-board-name', required=True, help='The Trello board name.')
parser.add_argument(
'--trello-board-id',
required=True,
help='The Trello board name.')
parser.add_argument('--trello-board-id',
help=('The Trello board short ID. Can usually be found ' 'in the URL of the Trello board.'),
)
parser.add_argument(
'--trello-api-key',
required=True,
help=('The Trello board short ID. Can usually be found '
'in the URL of the Trello board.'))
parser.add_argument('--trello-api-key',
help=(
'Visit https://trello.com/1/appkey/generate to generate '
'an APPLICATION_KEY (need to be logged into Trello).'
),
)
parser.add_argument(
'--trello-token',
required=True,
help=('Visit https://trello.com/1/appkey/generate to generate '
'an APPLICATION_KEY (need to be logged into Trello).'))
parser.add_argument('--trello-token',
required=True,
help=('Visit https://trello.com/1/appkey/generate and under '
help=(
'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.')
'a Trello access token.'
),
)
parser.add_argument(
'--zulip-webhook-url', required=True, help='The webhook URL that Trello will query.'
)
options = parser.parse_args()
create_webhook(options)
if __name__ == '__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.
"""
def write_config(config: ConfigParser, configfile_path: str) -> None:
with open(configfile_path, 'w') as configfile:
config.write(configfile)
parser = zulip.add_default_arguments(argparse.ArgumentParser("Fetch tweets from Twitter."))
parser.add_argument('--instructions',
parser.add_argument(
'--instructions',
action='store_true',
help='Show instructions for the twitter bot setup and exit'
)
parser.add_argument('--limit-tweets',
default=15,
type=int,
help='Maximum number of tweets to send at once')
parser.add_argument('--search',
dest='search_terms',
help='Terms to search on',
action='store')
parser.add_argument('--stream',
help='Show instructions for the twitter bot setup and exit',
)
parser.add_argument(
'--limit-tweets', default=15, type=int, help='Maximum number of tweets to send at once'
)
parser.add_argument('--search', dest='search_terms', help='Terms to search on', action='store')
parser.add_argument(
'--stream',
dest='stream',
help='The stream to which to send tweets',
default="twitter",
action='store')
parser.add_argument('--twitter-name',
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-users',
dest='excluded_users',
help='Users to exclude tweets on')
action='store',
)
parser.add_argument(
'--twitter-name', 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-users', dest='excluded_users', help='Users to exclude tweets on')
opts = parser.parse_args()
@ -150,18 +147,22 @@ try:
except ImportError:
parser.error("Please install python-twitter")
api = twitter.Api(consumer_key=consumer_key,
api = twitter.Api(
consumer_key=consumer_key,
consumer_secret=consumer_secret,
access_token_key=access_token_key,
access_token_secret=access_token_secret)
access_token_secret=access_token_secret,
)
user = api.VerifyCredentials()
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)
client = zulip.init_from_options(opts, client=client_type+VERSION)
client = zulip.init_from_options(opts, client=client_type + VERSION)
if opts.search_terms:
search_query = " OR ".join(opts.search_terms.split(","))
@ -190,7 +191,7 @@ if opts.excluded_users:
else:
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
exclude = False
for user in excluded_users:
@ -237,12 +238,7 @@ for status in statuses[::-1][:opts.limit_tweets]:
elif opts.twitter_name:
subject = composed
message = {
"type": "stream",
"to": [opts.stream],
"subject": subject,
"content": url
}
message = {"type": "stream", "to": [opts.stream], "subject": subject, "content": url}
ret = client.send_message(message)

View file

@ -13,25 +13,14 @@ import zephyr
import zulip
parser = optparse.OptionParser()
parser.add_option('--verbose',
dest='verbose',
default=False,
action='store_true')
parser.add_option('--site',
dest='site',
default=None,
action='store')
parser.add_option('--sharded',
default=False,
action='store_true')
parser.add_option('--verbose', dest='verbose', default=False, 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()
mit_user = 'tabbott/extra@ATHENA.MIT.EDU'
zulip_client = zulip.Client(
verbose=True,
client="ZulipMonitoring/0.1",
site=options.site)
zulip_client = zulip.Client(verbose=True, client="ZulipMonitoring/0.1", site=options.site)
# Configure logging
log_file = "/var/log/zulip/check-mirroring-log"
@ -75,13 +64,14 @@ if options.sharded:
for (stream, test) in test_streams:
if stream == "message":
continue
assert(hashlib.sha1(stream.encode("utf-8")).hexdigest().startswith(test))
assert hashlib.sha1(stream.encode("utf-8")).hexdigest().startswith(test)
else:
test_streams = [
("message", "p"),
("tabbott-nagios-test", "a"),
]
def print_status_and_exit(status: int) -> None:
# 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)
sys.exit(status)
def send_zulip(message: Dict[str, str]) -> None:
result = zulip_client.send_message(message)
if result["result"] != "success":
@ -99,11 +90,16 @@ def send_zulip(message: Dict[str, str]) -> None:
logger.error(str(result))
print_status_and_exit(1)
# Returns True if and only if we "Detected server failure" sending the zephyr.
def send_zephyr(zwrite_args: List[str], content: str) -> bool:
p = subprocess.Popen(zwrite_args, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True)
p = subprocess.Popen(
zwrite_args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
stdout, stderr = p.communicate(input=content)
if p.returncode != 0:
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)
return False
# Subscribe to Zulip
try:
res = zulip_client.register(event_types=["message"])
@ -164,6 +161,8 @@ if not actually_subscribed:
# Prepare keys
zhkeys = {} # type: Dict[str, Tuple[str, str]]
hzkeys = {} # type: Dict[str, Tuple[str, str]]
def gen_key(key_dict: Dict[str, Tuple[str, str]]) -> str:
bits = str(random.getrandbits(32))
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))
return bits
def gen_keys(key_dict: Dict[str, Tuple[str, str]]) -> None:
for (stream, test) in test_streams:
key_dict[gen_key(key_dict)] = (stream, test)
gen_keys(zhkeys)
gen_keys(hzkeys)
@ -196,6 +197,7 @@ def receive_zephyrs() -> None:
continue
notices.append(notice)
logger.info("Starting sending messages!")
# Send zephyrs
zsig = "Timothy Good Abbott"
@ -212,12 +214,15 @@ for key, (stream, test) in zhkeys.items():
zhkeys[new_key] = value
server_failure_again = send_zephyr(zwrite_args, str(new_key))
if server_failure_again:
logging.error("Zephyr server failure twice in a row on keys %s and %s! Aborting." %
(key, new_key))
logging.error(
"Zephyr server failure twice in a row on keys %s and %s! Aborting."
% (key, new_key)
)
print_status_and_exit(1)
else:
logging.warning("Replaced key %s with %s due to Zephyr server failure." %
(key, new_key))
logging.warning(
"Replaced key %s with %s due to Zephyr server failure." % (key, new_key)
)
receive_zephyrs()
receive_zephyrs()
@ -226,18 +231,22 @@ logger.info("Sent Zephyr messages!")
# Send Zulips
for key, (stream, test) in hzkeys.items():
if stream == "message":
send_zulip({
send_zulip(
{
"type": "private",
"content": str(key),
"to": zulip_client.email,
})
}
)
else:
send_zulip({
send_zulip(
{
"type": "stream",
"subject": "test",
"content": str(key),
"to": stream,
})
}
)
receive_zephyrs()
logger.info("Sent Zulip messages!")
@ -265,6 +274,8 @@ receive_zephyrs()
logger.info("Finished receiving Zephyr messages!")
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]:
# 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())
return key_counts, z_missing, h_missing, duplicates, success
# The h_foo variables are about the messages we _received_ in Zulip
# The z_foo variables are about the messages we _received_ in Zephyr
h_contents = [message["content"] for message in messages]
@ -302,12 +314,16 @@ for key in all_keys:
continue
if key in zhkeys:
(stream, test) = zhkeys[key]
logger.warning("%10s: z got %s, h got %s. Sent via Zephyr(%s): class %s" %
(key, z_key_counts[key], h_key_counts[key], test, stream))
logger.warning(
"%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:
(stream, test) = hzkeys[key]
logger.warning("%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.warning(
"%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("Summary of specific problems:")
@ -322,10 +338,14 @@ if z_duplicates:
if z_missing_z:
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:
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:
logger.error("zephyr: Didn't receive all the Zulips we sent on the Zephyr end!")
if z_missing_h == h_missing_h:

View file

@ -27,7 +27,9 @@ session_path = "/home/zulip/zephyr_sessions/%s" % (program_name,)
try:
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:
pass
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
# normalization and then lower-casing server-side
canonical_cls = unicodedata.normalize("NFKC", stream_name).lower()
if canonical_cls in ['security', 'login', 'network', 'ops', 'user_locate',
'mit', 'moof', 'wsmonitor', 'wg_ctl', 'winlogger',
'hm_ctl', 'hm_stat', 'zephyr_admin', 'zephyr_ctl']:
if canonical_cls in [
'security',
'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
# to MIT's Zephyr access control settings
continue
@ -30,6 +43,7 @@ def write_public_streams() -> None:
f.write(json.dumps(list(public_streams)) + "\n")
os.rename("/home/zulip/public_streams.tmp", "/home/zulip/public_streams")
if __name__ == "__main__":
log_file = "/home/zulip/sync_public_streams.log"
logger = logging.getLogger(__name__)
@ -83,9 +97,7 @@ if __name__ == "__main__":
last_event_id = max(last_event_id, event["id"])
if event["type"] == "stream":
if event["op"] == "create":
stream_names.update(
stream["name"] for stream in event["streams"]
)
stream_names.update(stream["name"] for stream in event["streams"])
write_public_streams()
elif event["op"] == "delete":
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)
os._exit(1)
signal.signal(signal.SIGINT, die)
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:
subprocess.call([options.on_startup_command])
from zerver.lib.parallel import run_parallel
print("Starting parallel zephyr class mirroring bot")
jobs = list("0123456789abcdef")
def run_job(shard: str) -> int:
subprocess.call(args + ["--shard=%s" % (shard,)])
return 0
for (status, job) in run_parallel(run_job, jobs, threads=16):
print("A mirroring shard died!")
sys.exit(0)

View file

@ -22,12 +22,16 @@ from zulip import RandomExponentialBackoff
DEFAULT_SITE = "https://api.zulip.com"
class States:
Startup, ZulipToZephyr, ZephyrToZulip, ChildSending = list(range(4))
CURRENT_STATE = States.Startup
logger: logging.Logger
def to_zulip_username(zephyr_username: str) -> str:
if "@" in zephyr_username:
(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() + "|" + realm.upper() + "@mit.edu"
def to_zephyr_username(zulip_username: str) -> str:
(user, realm) = zulip_username.split("@")
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,))
return match_user.group(1).lower() + "@" + match_user.group(2).upper()
# Checks whether the pair of adjacent lines would have been
# 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
@ -70,6 +76,7 @@ def different_paragraph(line: str, next_line: str) -> bool:
or len(line) < len(words[0])
)
# 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
def unwrap_lines(body: str) -> str:
@ -78,9 +85,8 @@ def unwrap_lines(body: str) -> str:
previous_line = lines[0]
for line in lines[1:]:
line = line.rstrip()
if (
re.match(r'^\W', line, flags=re.UNICODE)
and re.match(r'^\W', previous_line, flags=re.UNICODE)
if re.match(r'^\W', line, flags=re.UNICODE) and re.match(
r'^\W', previous_line, flags=re.UNICODE
):
result += previous_line + "\n"
elif (
@ -99,6 +105,7 @@ def unwrap_lines(body: str) -> str:
result += previous_line
return result
class ZephyrDict(TypedDict, total=False):
type: Literal["private", "stream"]
time: str
@ -109,6 +116,7 @@ class ZephyrDict(TypedDict, total=False):
content: str
zsig: str
def send_zulip(zeph: ZephyrDict) -> Dict[str, Any]:
message: Dict[str, Any]
message = {}
@ -142,15 +150,20 @@ def send_zulip(zeph: ZephyrDict) -> Dict[str, Any]:
return zulip_client.send_message(message)
def send_error_zulip(error_msg: str) -> None:
message = {"type": "private",
message = {
"type": "private",
"sender": zulip_account_email,
"to": zulip_account_email,
"content": error_msg,
}
zulip_client.send_message(message)
current_zephyr_subs = set()
def zephyr_bulk_subscribe(subs: List[Tuple[str, str, str]]) -> None:
try:
zephyr._z.subAll(subs)
@ -186,6 +199,7 @@ def zephyr_bulk_subscribe(subs: List[Tuple[str, str, str]]) -> None:
else:
current_zephyr_subs.add(cls)
def update_subscriptions() -> None:
try:
f = open(options.stream_file_path)
@ -198,10 +212,9 @@ def update_subscriptions() -> None:
classes_to_subscribe = set()
for stream in public_streams:
zephyr_class = stream
if (
options.shard is not None
and not hashlib.sha1(zephyr_class.encode("utf-8")).hexdigest().startswith(options.shard)
):
if options.shard is not None and not hashlib.sha1(
zephyr_class.encode("utf-8")
).hexdigest().startswith(options.shard):
# This stream is being handled by a different zephyr_mirror job.
continue
if zephyr_class in current_zephyr_subs:
@ -211,6 +224,7 @@ def update_subscriptions() -> None:
if len(classes_to_subscribe) > 0:
zephyr_bulk_subscribe(list(classes_to_subscribe))
def maybe_kill_child() -> None:
try:
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
logger.exception("")
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")
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("zephyr mirroring script has been updated; restarting...")
@ -244,6 +262,7 @@ def maybe_restart_mirroring_script() -> None:
backoff.fail()
raise Exception("Failed to reload too many times, aborting!")
def process_loop(log: Optional[IO[str]]) -> NoReturn:
restart_check_count = 0
last_check_time = time.time()
@ -287,6 +306,7 @@ def process_loop(log: Optional[IO[str]]) -> NoReturn:
except Exception:
logger.exception("Error updating subscriptions from Zulip:")
def parse_zephyr_body(zephyr_data: str, notice_format: str) -> Tuple[str, str]:
try:
(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')
if len(fields) == 5:
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:
(zsig, body) = ("", zephyr_data)
# Clean body of any null characters, since they're invalid in our protocol.
body = body.replace('\x00', '')
return (zsig, body)
def parse_crypt_table(zephyr_class: str, instance: str) -> Optional[str]:
try:
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() == "":
# Ignore blank lines
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:
# Malformed crypt_table line
logger.debug("Invalid crypt_table line!")
continue
groups = match.groupdict()
if groups['class'].lower() == zephyr_class and 'keypath' in groups and \
groups.get("algorithm") == "AES":
if (
groups['class'].lower() == zephyr_class
and 'keypath' in groups
and groups.get("algorithm") == "AES"
):
return groups["keypath"]
return None
def decrypt_zephyr(zephyr_class: str, instance: str, body: str) -> str:
keypath = parse_crypt_table(zephyr_class, instance)
if keypath is None:
@ -337,7 +369,9 @@ def decrypt_zephyr(zephyr_class: str, instance: str, body: str) -> str:
signal.signal(signal.SIGCHLD, signal.SIG_DFL)
# decrypt the message!
p = subprocess.Popen(["gpg",
p = subprocess.Popen(
[
"gpg",
"--decrypt",
"--no-options",
"--no-default-keyring",
@ -347,17 +381,20 @@ def decrypt_zephyr(zephyr_class: str, instance: str, body: str) -> str:
"--quiet",
"--no-use-agent",
"--passphrase-file",
keypath],
keypath,
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
errors="replace")
errors="replace",
)
decrypted, _ = p.communicate(input=body)
# Restore our ignoring signals
signal.signal(signal.SIGCHLD, signal.SIG_IGN)
return decrypted
def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
assert notice.sender is not None
(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:
return
if (zephyr_class not in current_zephyr_subs) and not is_personal:
logger.debug("Skipping ... %s/%s/%s" %
(zephyr_class, notice.instance, is_personal))
logger.debug("Skipping ... %s/%s/%s" % (zephyr_class, notice.instance, is_personal))
return
if notice.format.startswith("Zephyr error: See") or notice.format.endswith("@(@color(blue))"):
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:"):
is_huddle = True
# Map "CC: user1 user2" => "user1@mit.edu, user2@mit.edu"
huddle_recipients = [to_zulip_username(x.strip()) for x in
body.split("\n")[0][4:].split()]
huddle_recipients = [
to_zulip_username(x.strip()) for x in body.split("\n")[0][4:].split()
]
if notice.sender not in huddle_recipients:
huddle_recipients.append(to_zulip_username(notice.sender))
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)
zeph: ZephyrDict
zeph = {'time': str(notice.time),
zeph = {
'time': str(notice.time),
'sender': notice.sender,
'zsig': zsig, # logged here but not used by app
'content': body}
'content': body,
}
if is_huddle:
zeph['type'] = 'private'
zeph['recipient'] = huddle_recipients
@ -442,8 +485,9 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
heading = ""
zeph["content"] = heading + zeph["content"]
logger.info("Received a message on %s/%s from %s..." %
(zephyr_class, notice.instance, notice.sender))
logger.info(
"Received a message on %s/%s from %s..." % (zephyr_class, notice.instance, notice.sender)
)
if log is not None:
log.write(json.dumps(zeph) + '\n')
log.flush()
@ -461,11 +505,13 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
finally:
os._exit(0)
def quit_failed_initialization(message: str) -> str:
logger.error(message)
maybe_kill_child()
sys.exit(1)
def zephyr_init_autoretry() -> None:
backoff = zulip.RandomExponentialBackoff()
while backoff.keep_going():
@ -481,6 +527,7 @@ def zephyr_init_autoretry() -> None:
quit_failed_initialization("Could not initialize Zephyr library, quitting!")
def zephyr_load_session_autoretry(session_path: str) -> None:
backoff = zulip.RandomExponentialBackoff()
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!")
def zephyr_subscribe_autoretry(sub: Tuple[str, str, str]) -> None:
backoff = zulip.RandomExponentialBackoff()
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!")
def zephyr_to_zulip(options: optparse.Values) -> None:
if options.use_sessions and os.path.exists(options.session_path):
logger.info("Loading old session")
@ -542,9 +591,10 @@ def zephyr_to_zulip(options: optparse.Values) -> None:
zeph["stream"] = zeph["class"]
if "instance" in zeph:
zeph["subject"] = zeph["instance"]
logger.info("sending saved message to %s from %s..." %
(zeph.get('stream', zeph.get('recipient')),
zeph['sender']))
logger.info(
"sending saved message to %s from %s..."
% (zeph.get('stream', zeph.get('recipient')), zeph['sender'])
)
send_zulip(zeph)
except Exception:
logger.exception("Could not send saved zephyr:")
@ -558,36 +608,52 @@ def zephyr_to_zulip(options: optparse.Values) -> None:
else:
process_loop(None)
def send_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]:
p = subprocess.Popen(zwrite_args, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True)
p = subprocess.Popen(
zwrite_args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
stdout, stderr = p.communicate(input=content)
if p.returncode:
logger.error("zwrite command '%s' failed with return code %d:" % (
" ".join(zwrite_args), p.returncode,))
logger.error(
"zwrite command '%s' failed with return code %d:"
% (
" ".join(zwrite_args),
p.returncode,
)
)
if stdout:
logger.info("stdout: " + stdout)
elif stderr:
logger.warning("zwrite command '%s' printed the following warning:" % (
" ".join(zwrite_args),))
logger.warning(
"zwrite command '%s' printed the following warning:" % (" ".join(zwrite_args),)
)
if stderr:
logger.warning("stderr: " + stderr)
return (p.returncode, stderr)
def send_authed_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]:
return send_zephyr(zwrite_args, content)
def send_unauthed_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]:
return send_zephyr(zwrite_args + ["-d"], content)
def zcrypt_encrypt_content(zephyr_class: str, instance: str, content: str) -> Optional[str]:
keypath = parse_crypt_table(zephyr_class, instance)
if keypath is None:
return None
# encrypt the message!
p = subprocess.Popen(["gpg",
p = subprocess.Popen(
[
"gpg",
"--symmetric",
"--no-options",
"--no-default-keyring",
@ -597,16 +663,20 @@ def zcrypt_encrypt_content(zephyr_class: str, instance: str, content: str) -> Op
"--quiet",
"--no-use-agent",
"--armor",
"--cipher-algo", "AES",
"--cipher-algo",
"AES",
"--passphrase-file",
keypath],
keypath,
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True)
universal_newlines=True,
)
encrypted, _ = p.communicate(input=content)
return encrypted
def forward_to_zephyr(message: Dict[str, Any]) -> None:
# 'Any' can be of any type of text
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."""
wrapper = textwrap.TextWrapper(break_long_words=False, break_on_hyphens=False)
wrapped_content = "\n".join("\n".join(wrapper.wrap(line))
for line in message["content"].replace("@", "@@").split("\n"))
wrapped_content = "\n".join(
"\n".join(wrapper.wrap(line)) for line in message["content"].replace("@", "@@").split("\n")
)
zwrite_args = ["zwrite", "-n", "-s", message["sender_full_name"],
"-F", "Zephyr error: See http://zephyr.1ts.org/wiki/df",
"-x", "UTF-8"]
zwrite_args = [
"zwrite",
"-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 :)
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
# appropriate WHITESPACE instance for bidirectional mirroring
instance = match_whitespace_instance.group(1)
elif (
instance == "instance %s" % (zephyr_class,)
or instance == "test instance %s" % (zephyr_class,)
elif instance == "instance %s" % (zephyr_class,) or instance == "test instance %s" % (
zephyr_class,
):
# Forward messages to e.g. -c -i white-magic back from the
# place we forward them to
@ -663,15 +740,18 @@ Feedback button or at support@zulip.com."""
zwrite_args.extend(["-C"])
# We drop the @ATHENA.MIT.EDU here because otherwise the
# "CC: user1 user2 ..." output will be unnecessarily verbose.
recipients = [to_zephyr_username(user["email"]).replace("@ATHENA.MIT.EDU", "")
for user in message["display_recipient"]]
recipients = [
to_zephyr_username(user["email"]).replace("@ATHENA.MIT.EDU", "")
for user in message["display_recipient"]
]
logger.info("Forwarding message to %s" % (recipients,))
zwrite_args.extend(recipients)
if message.get("invite_only_stream"):
result = zcrypt_encrypt_content(zephyr_class, instance, wrapped_content)
if result is None:
send_error_zulip("""%s
send_error_zulip(
"""%s
Your Zulip-Zephyr mirror bot was unable to forward that last message \
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 \
Zulip users (like you) received it, Zephyr users did not.
%s""" % (support_heading, support_closing))
%s"""
% (support_heading, support_closing)
)
return
# 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"])
if options.test_mode:
logger.debug("Would have forwarded: %s\n%s" %
(zwrite_args, wrapped_content))
logger.debug("Would have forwarded: %s\n%s" % (zwrite_args, wrapped_content))
return
(code, stderr) = send_authed_zephyr(zwrite_args, wrapped_content)
if code == 0 and stderr == "":
return
elif code == 0:
send_error_zulip("""%s
send_error_zulip(
"""%s
Your last message was successfully mirrored to zephyr, but zwrite \
returned the following warning:
%s
%s""" % (support_heading, stderr, support_closing))
%s"""
% (support_heading, stderr, support_closing)
)
return
elif code != 0 and (
stderr.startswith("zwrite: Ticket expired while sending notice to ")
@ -714,7 +798,8 @@ returned the following warning:
if code == 0:
if options.ignore_expired_tickets:
return
send_error_zulip("""%s
send_error_zulip(
"""%s
Your last message was forwarded from Zulip to Zephyr unauthenticated, \
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 \
authenticated Zephyr messages for you again.
%s""" % (support_heading, support_closing))
%s"""
% (support_heading, support_closing)
)
return
# zwrite failed and it wasn't because of expired tickets: This is
# probably because the recipient isn't subscribed to personals,
# 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 \
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""" % (support_heading, stderr, support_closing))
%s"""
% (support_heading, stderr, support_closing)
)
return
def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None:
# 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 (
(message["type"] == "stream")
or (
message["type"] == "private"
and False
not in [
u["email"].lower().endswith("mit.edu")
for u in message["display_recipient"]
u["email"].lower().endswith("mit.edu") for u in message["display_recipient"]
]
)
):
@ -758,8 +848,9 @@ def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None:
return
timestamp_now = int(time.time())
if float(message["timestamp"]) < timestamp_now - 15:
logger.warning("Skipping out of order message: %s < %s" %
(message["timestamp"], timestamp_now))
logger.warning(
"Skipping out of order message: %s < %s" % (message["timestamp"], timestamp_now)
)
return
try:
forward_to_zephyr(message)
@ -768,6 +859,7 @@ def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None:
# whole process
logger.exception("Error forwarding message:")
def zulip_to_zephyr(options: optparse.Values) -> NoReturn:
# Sync messages from zulip to zephyr
logger.info("Starting syncing messages.")
@ -779,6 +871,7 @@ def zulip_to_zephyr(options: optparse.Values) -> NoReturn:
logger.exception("Error syncing messages:")
backoff.fail()
def subscribed_to_mail_messages() -> bool:
# 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
@ -787,12 +880,13 @@ def subscribed_to_mail_messages() -> bool:
if stored_result is not None:
return stored_result == "True"
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"
return True
os.environ["HUMBUG_FORWARD_MAIL_ZEPHYRS"] = "False"
return False
def add_zulip_subscriptions(verbose: bool) -> None:
zephyr_subscriptions = set()
skipped = set()
@ -805,7 +899,14 @@ def add_zulip_subscriptions(verbose: bool) -> None:
# We don't support subscribing to (message, *)
if instance == "*":
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
# If you're on -i white-magic on zephyr, get on stream white-magic 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)
if len(zephyr_subscriptions) != 0:
res = zulip_client.add_subscriptions(list({"name": stream} for stream in zephyr_subscriptions),
authorization_errors_fatal=False)
res = zulip_client.add_subscriptions(
list({"name": stream} for stream in zephyr_subscriptions),
authorization_errors_fatal=False,
)
if res.get("result") != "success":
logger.error("Error subscribing to streams:\n%s" % (res["msg"],))
return
@ -839,9 +942,15 @@ def add_zulip_subscriptions(verbose: bool) -> None:
if already is not None and len(already) > 0:
logger.info("\nAlready subscribed to: %s" % (", ".join(list(already.values())[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:
logger.info("\n" + "\n".join(textwrap.wrap("""\
logger.info(
"\n"
+ "\n".join(
textwrap.wrap(
"""\
The following streams you have NOT been subscribed to,
because they have been configured in Zulip as invitation-only streams.
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
on these streams and already use Zulip. They can subscribe you to them via the
"streams" page in the Zulip web interface:
""")) + "\n\n %s" % (", ".join(unauthorized),))
"""
)
)
+ "\n\n %s" % (", ".join(unauthorized),)
)
if len(skipped) > 0:
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
synced to your Zulip subscriptions because they do not
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
stream, so this tool has not created a corresponding
Zulip subscription to these lines in ~/.zephyr.subs:
""")) + "\n")
"""
)
)
+ "\n"
)
for (cls, instance, recipient, reason) in skipped:
if verbose:
@ -873,15 +994,25 @@ Zulip subscription to these lines in ~/.zephyr.subs:
logger.info(" [%s,%s,%s]" % (cls, instance, recipient))
if len(skipped) > 0:
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
to these .zephyrs.subs lines, please do so via the Zulip
web interface.
""")) + "\n")
"""
)
)
+ "\n"
)
def valid_stream_name(name: str) -> bool:
return name != ""
def parse_zephyr_subs(verbose: bool = False) -> Set[Tuple[str, str, str]]:
zephyr_subscriptions = set() # type: Set[Tuple[str, str, str]]
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()))
return zephyr_subscriptions
def open_logger() -> logging.Logger:
if options.log_path is not None:
log_file = options.log_path
@ -919,8 +1051,7 @@ def open_logger() -> logging.Logger:
else:
log_file = "/var/log/zulip/mirror-log"
else:
f = tempfile.NamedTemporaryFile(prefix="zulip-log.%s." % (options.user,),
delete=False)
f = tempfile.NamedTemporaryFile(prefix="zulip-log.%s." % (options.user,), delete=False)
log_file = f.name
# Close the file descriptor, since the logging system will
# reopen it anyway.
@ -935,6 +1066,7 @@ def open_logger() -> logging.Logger:
logger.addHandler(file_handler)
return logger
def configure_logger(logger: logging.Logger, direction_name: Optional[str]) -> None:
if direction_name is None:
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:
handler.setFormatter(formatter)
def parse_args() -> Tuple[optparse.Values, List[str]]:
parser = optparse.OptionParser()
parser.add_option('--forward-class-messages',
default=False,
help=optparse.SUPPRESS_HELP,
action='store_true')
parser.add_option('--shard',
help=optparse.SUPPRESS_HELP)
parser.add_option('--noshard',
default=False,
help=optparse.SUPPRESS_HELP,
action='store_true')
parser.add_option('--resend-log',
dest='logs_to_resend',
help=optparse.SUPPRESS_HELP)
parser.add_option('--enable-resend-log',
dest='resend_log_path',
help=optparse.SUPPRESS_HELP)
parser.add_option('--log-path',
dest='log_path',
help=optparse.SUPPRESS_HELP)
parser.add_option('--stream-file-path',
parser.add_option(
'--forward-class-messages', default=False, help=optparse.SUPPRESS_HELP, action='store_true'
)
parser.add_option('--shard', help=optparse.SUPPRESS_HELP)
parser.add_option('--noshard', default=False, help=optparse.SUPPRESS_HELP, action='store_true')
parser.add_option('--resend-log', dest='logs_to_resend', help=optparse.SUPPRESS_HELP)
parser.add_option('--enable-resend-log', dest='resend_log_path', help=optparse.SUPPRESS_HELP)
parser.add_option('--log-path', dest='log_path', help=optparse.SUPPRESS_HELP)
parser.add_option(
'--stream-file-path',
dest='stream_file_path',
default="/home/zulip/public_streams",
help=optparse.SUPPRESS_HELP)
parser.add_option('--no-forward-personals',
help=optparse.SUPPRESS_HELP,
)
parser.add_option(
'--no-forward-personals',
dest='forward_personals',
help=optparse.SUPPRESS_HELP,
default=True,
action='store_false')
parser.add_option('--forward-mail-zephyrs',
action='store_false',
)
parser.add_option(
'--forward-mail-zephyrs',
dest='forward_mail_zephyrs',
help=optparse.SUPPRESS_HELP,
default=False,
action='store_true')
parser.add_option('--no-forward-from-zulip',
action='store_true',
)
parser.add_option(
'--no-forward-from-zulip',
default=True,
dest='forward_from_zulip',
help=optparse.SUPPRESS_HELP,
action='store_false')
parser.add_option('--verbose',
default=False,
help=optparse.SUPPRESS_HELP,
action='store_true')
parser.add_option('--sync-subscriptions',
default=False,
action='store_true')
parser.add_option('--ignore-expired-tickets',
default=False,
action='store_true')
parser.add_option('--site',
default=DEFAULT_SITE,
help=optparse.SUPPRESS_HELP)
parser.add_option('--on-startup-command',
default=None,
help=optparse.SUPPRESS_HELP)
parser.add_option('--user',
default=os.environ["USER"],
help=optparse.SUPPRESS_HELP)
parser.add_option('--stamp-path',
action='store_false',
)
parser.add_option('--verbose', default=False, help=optparse.SUPPRESS_HELP, action='store_true')
parser.add_option('--sync-subscriptions', default=False, action='store_true')
parser.add_option('--ignore-expired-tickets', default=False, action='store_true')
parser.add_option('--site', default=DEFAULT_SITE, help=optparse.SUPPRESS_HELP)
parser.add_option('--on-startup-command', default=None, help=optparse.SUPPRESS_HELP)
parser.add_option('--user', default=os.environ["USER"], help=optparse.SUPPRESS_HELP)
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"))
)
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()
def die_gracefully(signal: int, frame: FrameType) -> None:
if CURRENT_STATE == States.ZulipToZephyr or CURRENT_STATE == States.ChildSending:
# 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)
if __name__ == "__main__":
# Set the SIGCHLD handler back to SIG_DFL to prevent these errors
# when importing the "requests" module after being restarted using
@ -1070,10 +1184,18 @@ if __name__ == "__main__":
api_key = os.environ.get("HUMBUG_API_KEY")
else:
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.
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)
api_key = open(options.api_key_file).read().strip()
# 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"
import zulip
zulip_client = zulip.Client(
email=zulip_account_email,
api_key=api_key,
verbose=True,
client="zephyr_mirror",
site=options.site)
site=options.site,
)
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:
# Personals mirror on behalf of another user.
pgrep_query = "%s.*--user=%s" % (pgrep_query, options.user)
proc = subprocess.Popen(['pgrep', '-U', os.environ["USER"], "-f", pgrep_query],
proc = subprocess.Popen(
['pgrep', '-U', os.environ["USER"], "-f", pgrep_query],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
stderr=subprocess.PIPE,
)
out, _err_unused = proc.communicate()
for pid in map(int, out.split()):
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
import zephyr
logger_name = "zephyr=>zulip"
if options.shard is not None:
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:
long_description = fh.read()
def version() -> str:
version_py = os.path.join(os.path.dirname(__file__), "zulip", "__init__.py")
with open(version_py) as in_handle:
version_line = next(itertools.dropwhile(lambda x: not x.startswith("__version__"),
in_handle))
version_line = next(
itertools.dropwhile(lambda x: not x.startswith("__version__"), in_handle)
)
version = version_line.split('=')[-1].strip().replace('"', '')
return version
def recur_expand(target_root: Any, dir: Any) -> Generator[Tuple[str, List[str]], None, None]:
for root, _, files in os.walk(dir):
paths = [os.path.join(root, f) for f in files]
if len(paths):
yield os.path.join(target_root, root), paths
# We should be installable with either setuptools or distutils.
package_info = dict(
name='zulip',
@ -56,14 +60,15 @@ package_info = dict(
'zulip-send=zulip.send:main',
'zulip-api-examples=zulip.api_examples: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"]},
) # type: Dict[str, Any]
setuptools_info = dict(
install_requires=['requests[security]>=0.12.1',
install_requires=[
'requests[security]>=0.12.1',
'matrix_client',
'distro',
'click',
@ -72,6 +77,7 @@ setuptools_info = dict(
try:
from setuptools import find_packages, setup
package_info.update(setuptools_info)
package_info['packages'] = find_packages(exclude=['tests'])
@ -82,7 +88,8 @@ except ImportError:
# Manual dependency check
try:
import requests
assert(LooseVersion(requests.__version__) >= LooseVersion('0.12.1'))
assert LooseVersion(requests.__version__) >= LooseVersion('0.12.1')
except (ImportError, AssertionError):
print("requests >=0.12.1 is not installed", file=sys.stderr)
sys.exit(1)

View file

@ -12,7 +12,6 @@ from zulip import ZulipError
class TestDefaultArguments(TestCase):
def test_invalid_arguments(self) -> None:
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage="lorem ipsum"))
with self.assertRaises(SystemExit) as cm:
@ -20,14 +19,18 @@ class TestDefaultArguments(TestCase):
parser.parse_args(['invalid argument'])
self.assertEqual(cm.exception.code, 2)
# 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:
-h, --help show this help message and exit
Zulip API configuration:
--site ZULIP_SITE Zulip server URI
"""))
"""
)
)
@patch('os.path.exists', return_value=False)
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:
zulip.init_from_options(args)
expanded_test_path = os.path.abspath(os.path.expanduser(test_path))
self.assertEqual(str(cm.exception), 'api_key or email not specified and '
'file {} does not exist'.format(expanded_test_path))
self.assertEqual(
str(cm.exception),
'api_key or email not specified and '
'file {} does not exist'.format(expanded_test_path),
)
if __name__ == '__main__':
unittest.main()

View file

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

View file

@ -39,12 +39,13 @@ logger = logging.getLogger(__name__)
# Check that we have a recent enough version
# Older versions don't provide the 'json' attribute on responses.
assert(LooseVersion(requests.__version__) >= LooseVersion('0.12.1'))
assert LooseVersion(requests.__version__) >= LooseVersion('0.12.1')
# In newer versions, the 'json' attribute is a function, not a property
requests_json_is_function = callable(requests.Response.json)
API_VERSTRING = "v1/"
class CountingBackoff:
def __init__(
self,
@ -83,8 +84,7 @@ class CountingBackoff:
def fail(self) -> None:
self._check_success_timeout()
self.number_of_retries = min(self.number_of_retries + 1,
self.maximum_retries)
self.number_of_retries = min(self.number_of_retries + 1, self.maximum_retries)
self.last_attempt_time = time.time()
def _check_success_timeout(self) -> None:
@ -95,6 +95,7 @@ class CountingBackoff:
):
self.number_of_retries = 0
class RandomExponentialBackoff(CountingBackoff):
def fail(self) -> None:
super().fail()
@ -109,9 +110,11 @@ class RandomExponentialBackoff(CountingBackoff):
print(message)
time.sleep(delay)
def _default_client() -> str:
return "ZulipPython/" + __version__
def add_default_arguments(
parser: argparse.ArgumentParser,
patch_error_handling: bool = True,
@ -119,125 +122,141 @@ def add_default_arguments(
) -> argparse.ArgumentParser:
if patch_error_handling:
def custom_error_handling(self: argparse.ArgumentParser, message: str) -> None:
self.print_help(sys.stderr)
self.exit(2, '{}: error: {}\n'.format(self.prog, message))
parser.error = types.MethodType(custom_error_handling, parser) # type: ignore # patching function
if allow_provisioning:
parser.add_argument('--provision',
parser.add_argument(
'--provision',
action='store_true',
dest="provision",
help="install dependencies for this script (found in requirements.txt)")
help="install dependencies for this script (found in requirements.txt)",
)
group = parser.add_argument_group('Zulip API configuration')
group.add_argument('--site',
dest="zulip_site",
help="Zulip server URI",
default=None)
group.add_argument('--api-key',
dest="zulip_api_key",
action='store')
group.add_argument('--user',
dest='zulip_email',
help='Email address of the calling bot or user.')
group.add_argument('--config-file',
group.add_argument('--site', dest="zulip_site", help="Zulip server URI", default=None)
group.add_argument('--api-key', dest="zulip_api_key", action='store')
group.add_argument(
'--user', dest='zulip_email', help='Email address of the calling bot or user.'
)
group.add_argument(
'--config-file',
action='store',
dest="zulip_config_file",
help='''Location of an ini file containing the above
information. (default ~/.zuliprc)''')
group.add_argument('-v', '--verbose',
action='store_true',
help='Provide detailed output.')
group.add_argument('--client',
action='store',
default=None,
dest="zulip_client",
help=argparse.SUPPRESS)
group.add_argument('--insecure',
information. (default ~/.zuliprc)''',
)
group.add_argument('-v', '--verbose', action='store_true', help='Provide detailed output.')
group.add_argument(
'--client', action='store', default=None, dest="zulip_client", help=argparse.SUPPRESS
)
group.add_argument(
'--insecure',
action='store_true',
dest='insecure',
help='''Do not verify the server certificate.
The https connection will not be secure.''')
group.add_argument('--cert-bundle',
The https connection will not be secure.''',
)
group.add_argument(
'--cert-bundle',
action='store',
dest='cert_bundle',
help='''Specify a file containing either the
server certificate, or a set of trusted
CA certificates. This will be used to
verify the server's identity. All
certificates should be PEM encoded.''')
group.add_argument('--client-cert',
certificates should be PEM encoded.''',
)
group.add_argument(
'--client-cert',
action='store',
dest='client_cert',
help='''Specify a file containing a client
certificate (not needed for most deployments).''')
group.add_argument('--client-cert-key',
certificate (not needed for most deployments).''',
)
group.add_argument(
'--client-cert-key',
action='store',
dest='client_cert_key',
help='''Specify a file containing the client
certificate's key (if it is in a separate
file).''')
file).''',
)
return parser
# This method might seem redundant with `add_default_arguments()`,
# except for the fact that is uses the deprecated `optparse` module.
# We still keep it for legacy support of out-of-tree bots and integrations
# depending on it.
def generate_option_group(parser: optparse.OptionParser, prefix: str = '') -> optparse.OptionGroup:
logging.warning("""zulip.generate_option_group is based on optparse, which
logging.warning(
"""zulip.generate_option_group is based on optparse, which
is now deprecated. We recommend migrating to argparse and
using zulip.add_default_arguments instead.""")
using zulip.add_default_arguments instead."""
)
group = optparse.OptionGroup(parser, 'Zulip API configuration')
group.add_option('--%ssite' % (prefix,),
dest="zulip_site",
help="Zulip server URI",
default=None)
group.add_option('--%sapi-key' % (prefix,),
dest="zulip_api_key",
action='store')
group.add_option('--%suser' % (prefix,),
dest='zulip_email',
help='Email address of the calling bot or user.')
group.add_option('--%sconfig-file' % (prefix,),
group.add_option(
'--%ssite' % (prefix,), dest="zulip_site", help="Zulip server URI", default=None
)
group.add_option('--%sapi-key' % (prefix,), dest="zulip_api_key", action='store')
group.add_option(
'--%suser' % (prefix,), dest='zulip_email', help='Email address of the calling bot or user.'
)
group.add_option(
'--%sconfig-file' % (prefix,),
action='store',
dest="zulip_config_file",
help='Location of an ini file containing the\nabove information. (default ~/.zuliprc)')
group.add_option('-v', '--verbose',
action='store_true',
help='Provide detailed output.')
group.add_option('--%sclient' % (prefix,),
help='Location of an ini file containing the\nabove information. (default ~/.zuliprc)',
)
group.add_option('-v', '--verbose', action='store_true', help='Provide detailed output.')
group.add_option(
'--%sclient' % (prefix,),
action='store',
default=None,
dest="zulip_client",
help=optparse.SUPPRESS_HELP)
group.add_option('--insecure',
help=optparse.SUPPRESS_HELP,
)
group.add_option(
'--insecure',
action='store_true',
dest='insecure',
help='''Do not verify the server certificate.
The https connection will not be secure.''')
group.add_option('--cert-bundle',
The https connection will not be secure.''',
)
group.add_option(
'--cert-bundle',
action='store',
dest='cert_bundle',
help='''Specify a file containing either the
server certificate, or a set of trusted
CA certificates. This will be used to
verify the server's identity. All
certificates should be PEM encoded.''')
group.add_option('--client-cert',
certificates should be PEM encoded.''',
)
group.add_option(
'--client-cert',
action='store',
dest='client_cert',
help='''Specify a file containing a client
certificate (not needed for most deployments).''')
group.add_option('--client-cert-key',
certificate (not needed for most deployments).''',
)
group.add_option(
'--client-cert-key',
action='store',
dest='client_cert_key',
help='''Specify a file containing the client
certificate's key (if it is in a separate
file).''')
file).''',
)
return group
def init_from_options(options: Any, client: Optional[str] = None) -> 'Client':
if getattr(options, 'provision', False):
@ -246,39 +265,54 @@ def init_from_options(options: Any, client: Optional[str] = None) -> 'Client':
import pip
except ImportError:
traceback.print_exc()
print("Module `pip` is not installed. To install `pip`, follow the instructions here: "
"https://pip.pypa.io/en/stable/installing/")
print(
"Module `pip` is not installed. To install `pip`, follow the instructions here: "
"https://pip.pypa.io/en/stable/installing/"
)
sys.exit(1)
if not pip.main(['install', '--upgrade', '--requirement', requirements_path]):
print("{color_green}You successfully provisioned the dependencies for {script}.{end_color}".format(
color_green='\033[92m', end_color='\033[0m',
script=os.path.splitext(os.path.basename(sys.argv[0]))[0]))
print(
"{color_green}You successfully provisioned the dependencies for {script}.{end_color}".format(
color_green='\033[92m',
end_color='\033[0m',
script=os.path.splitext(os.path.basename(sys.argv[0]))[0],
)
)
sys.exit(0)
if options.zulip_client is not None:
client = options.zulip_client
elif client is None:
client = _default_client()
return Client(email=options.zulip_email, api_key=options.zulip_api_key,
config_file=options.zulip_config_file, verbose=options.verbose,
site=options.zulip_site, client=client,
cert_bundle=options.cert_bundle, insecure=options.insecure,
return Client(
email=options.zulip_email,
api_key=options.zulip_api_key,
config_file=options.zulip_config_file,
verbose=options.verbose,
site=options.zulip_site,
client=client,
cert_bundle=options.cert_bundle,
insecure=options.insecure,
client_cert=options.client_cert,
client_cert_key=options.client_cert_key)
client_cert_key=options.client_cert_key,
)
def get_default_config_filename() -> Optional[str]:
if os.environ.get("HOME") is None:
return None
config_file = os.path.join(os.environ["HOME"], ".zuliprc")
if (
not os.path.exists(config_file)
and os.path.exists(os.path.join(os.environ["HOME"], ".humbugrc"))
if not os.path.exists(config_file) and os.path.exists(
os.path.join(os.environ["HOME"], ".humbugrc")
):
raise ZulipError("The Zulip API configuration file is now ~/.zuliprc; please run:\n\n"
" mv ~/.humbugrc ~/.zuliprc\n")
raise ZulipError(
"The Zulip API configuration file is now ~/.zuliprc; please run:\n\n"
" mv ~/.humbugrc ~/.zuliprc\n"
)
return config_file
def validate_boolean_field(field: Optional[Text]) -> Union[bool, None]:
if not isinstance(field, str):
return None
@ -292,24 +326,38 @@ def validate_boolean_field(field: Optional[Text]) -> Union[bool, None]:
else:
return None
class ZulipError(Exception):
pass
class ConfigNotFoundError(ZulipError):
pass
class MissingURLError(ZulipError):
pass
class UnrecoverableNetworkError(ZulipError):
pass
class Client:
def __init__(self, email: Optional[str] = None, api_key: Optional[str] = None, config_file: Optional[str] = None,
verbose: bool = False, retry_on_errors: bool = True,
site: Optional[str] = None, client: Optional[str] = None,
cert_bundle: Optional[str] = None, insecure: Optional[bool] = None,
client_cert: Optional[str] = None, client_cert_key: Optional[str] = None) -> None:
def __init__(
self,
email: Optional[str] = None,
api_key: Optional[str] = None,
config_file: Optional[str] = None,
verbose: bool = False,
retry_on_errors: bool = True,
site: Optional[str] = None,
client: Optional[str] = None,
cert_bundle: Optional[str] = None,
insecure: Optional[bool] = None,
client_cert: Optional[str] = None,
client_cert_key: Optional[str] = None,
) -> None:
if client is None:
client = _default_client()
@ -340,10 +388,11 @@ class Client:
insecure = validate_boolean_field(insecure_setting)
if insecure is None:
raise ZulipError("The ZULIP_ALLOW_INSECURE environment "
raise ZulipError(
"The ZULIP_ALLOW_INSECURE environment "
"variable is set to '{}', it must be "
"'true' or 'false'"
.format(insecure_setting))
"'true' or 'false'".format(insecure_setting)
)
if config_file is None:
config_file = get_default_config_filename()
@ -371,15 +420,19 @@ class Client:
insecure = validate_boolean_field(insecure_setting)
if insecure is None:
raise ZulipError("insecure is set to '{}', it must be "
"'true' or 'false' if it is used in {}"
.format(insecure_setting, config_file))
raise ZulipError(
"insecure is set to '{}', it must be "
"'true' or 'false' if it is used in {}".format(
insecure_setting, config_file
)
)
elif None in (api_key, email):
raise ConfigNotFoundError("api_key or email not specified and file %s does not exist"
% (config_file,))
raise ConfigNotFoundError(
"api_key or email not specified and file %s does not exist" % (config_file,)
)
assert(api_key is not None and email is not None)
assert api_key is not None and email is not None
self.api_key = api_key
self.email = email
self.verbose = verbose
@ -401,14 +454,15 @@ class Client:
self.client_name = client
if insecure:
logger.warning('Insecure mode enabled. The server\'s SSL/TLS '
logger.warning(
'Insecure mode enabled. The server\'s SSL/TLS '
'certificate will not be validated, making the '
'HTTPS connection potentially insecure')
'HTTPS connection potentially insecure'
)
self.tls_verification = False # type: Union[bool, str]
elif cert_bundle is not None:
if not os.path.isfile(cert_bundle):
raise ConfigNotFoundError("tls bundle '%s' does not exist"
% (cert_bundle,))
raise ConfigNotFoundError("tls bundle '%s' does not exist" % (cert_bundle,))
self.tls_verification = cert_bundle
else:
# Default behavior: verify against system CA certificates
@ -416,16 +470,18 @@ class Client:
if client_cert is None:
if client_cert_key is not None:
raise ConfigNotFoundError("client cert key '%s' specified, but no client cert public part provided"
% (client_cert_key,))
raise ConfigNotFoundError(
"client cert key '%s' specified, but no client cert public part provided"
% (client_cert_key,)
)
else: # we have a client cert
if not os.path.isfile(client_cert):
raise ConfigNotFoundError("client cert '%s' does not exist"
% (client_cert,))
raise ConfigNotFoundError("client cert '%s' does not exist" % (client_cert,))
if client_cert_key is not None:
if not os.path.isfile(client_cert_key):
raise ConfigNotFoundError("client cert key '%s' does not exist"
% (client_cert_key,))
raise ConfigNotFoundError(
"client cert key '%s' does not exist" % (client_cert_key,)
)
self.client_cert = client_cert
self.client_cert_key = client_cert_key
@ -442,8 +498,11 @@ class Client:
# Build a client cert object for requests
if self.client_cert_key is not None:
assert(self.client_cert is not None) # Otherwise ZulipError near end of __init__
client_cert = (self.client_cert, self.client_cert_key) # type: Union[None, str, Tuple[str, str]]
assert self.client_cert is not None # Otherwise ZulipError near end of __init__
client_cert = (
self.client_cert,
self.client_cert_key,
) # type: Union[None, str, Tuple[str, str]]
else:
client_cert = self.client_cert
@ -479,8 +538,15 @@ class Client:
vendor_version=vendor_version,
)
def do_api_query(self, orig_request: Mapping[str, Any], url: str, method: str = "POST",
longpolling: bool = False, files: Optional[List[IO[Any]]] = None, timeout: Optional[float] = None) -> Dict[str, Any]:
def do_api_query(
self,
orig_request: Mapping[str, Any],
url: str,
method: str = "POST",
longpolling: bool = False,
files: Optional[List[IO[Any]]] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
if files is None:
files = []
@ -488,10 +554,10 @@ class Client:
# When long-polling, set timeout to 90 sec as a balance
# between a low traffic rate and a still reasonable latency
# time in case of a connection failure.
request_timeout = 90.
request_timeout = 90.0
else:
# Otherwise, 15s should be plenty of time.
request_timeout = 15. if not timeout else timeout
request_timeout = 15.0 if not timeout else timeout
request = {}
req_files = []
@ -506,7 +572,7 @@ class Client:
req_files.append((f.name, f))
self.ensure_session()
assert(self.session is not None)
assert self.session is not None
query_state = {
'had_error_retry': False,
@ -519,8 +585,13 @@ class Client:
return False
if self.verbose:
if not query_state["had_error_retry"]:
sys.stdout.write("zulip API(%s): connection error%s -- retrying." %
(url.split(API_VERSTRING, 2)[0], error_string,))
sys.stdout.write(
"zulip API(%s): connection error%s -- retrying."
% (
url.split(API_VERSTRING, 2)[0],
error_string,
)
)
query_state["had_error_retry"] = True
else:
sys.stdout.write(".")
@ -554,7 +625,8 @@ class Client:
method,
urllib.parse.urljoin(self.base_url, url),
timeout=request_timeout,
**kwargs)
**kwargs,
)
self.has_connected = True
@ -578,8 +650,10 @@ class Client:
continue
else:
end_error_retry(False)
return {'msg': "Connection error:\n%s" % (traceback.format_exc(),),
"result": "connection-error"}
return {
'msg': "Connection error:\n%s" % (traceback.format_exc(),),
"result": "connection-error",
}
except requests.exceptions.ConnectionError:
if not self.has_connected:
# If we have never successfully connected to the server, don't
@ -591,12 +665,16 @@ class Client:
if error_retry(""):
continue
end_error_retry(False)
return {'msg': "Connection error:\n%s" % (traceback.format_exc(),),
"result": "connection-error"}
return {
'msg': "Connection error:\n%s" % (traceback.format_exc(),),
"result": "connection-error",
}
except Exception:
# We'll split this out into more cases as we encounter new bugs.
return {'msg': "Unexpected error:\n%s" % (traceback.format_exc(),),
"result": "unexpected-error"}
return {
'msg': "Unexpected error:\n%s" % (traceback.format_exc(),),
"result": "unexpected-error",
}
try:
if requests_json_is_function:
@ -610,11 +688,21 @@ class Client:
end_error_retry(True)
return json_result
end_error_retry(False)
return {'msg': "Unexpected error from the server", "result": "http-error",
"status_code": res.status_code}
return {
'msg': "Unexpected error from the server",
"result": "http-error",
"status_code": res.status_code,
}
def call_endpoint(self, url: Optional[str] = None, method: str = "POST", request: Optional[Dict[str, Any]] = None,
longpolling: bool = False, files: Optional[List[IO[Any]]] = None, timeout: Optional[float] = None) -> Dict[str, Any]:
def call_endpoint(
self,
url: Optional[str] = None,
method: str = "POST",
request: Optional[Dict[str, Any]] = None,
longpolling: bool = False,
files: Optional[List[IO[Any]]] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
if request is None:
request = dict()
marshalled_request = {}
@ -622,8 +710,14 @@ class Client:
if v is not None:
marshalled_request[k] = v
versioned_url = API_VERSTRING + (url if url is not None else "")
return self.do_api_query(marshalled_request, versioned_url, method=method,
longpolling=longpolling, files=files, timeout=timeout)
return self.do_api_query(
marshalled_request,
versioned_url,
method=method,
longpolling=longpolling,
files=files,
timeout=timeout,
)
def call_on_each_event(
self,
@ -664,7 +758,9 @@ class Client:
print("HTTP error fetching events -- probably a server restart")
elif res["result"] == "connection-error":
if self.verbose:
print("Connection error fetching events -- probably server is temporarily down?")
print(
"Connection error fetching events -- probably server is temporarily down?"
)
else:
if self.verbose:
print("Server returned error:\n%s" % (res["msg"],))
@ -672,7 +768,9 @@ class Client:
# BAD_EVENT_QUEUE_ID check, but we check for the
# old string to support legacy Zulip servers. We
# should remove that legacy check in 2019.
if res.get("code") == "BAD_EVENT_QUEUE_ID" or res["msg"].startswith("Bad event queue id:"):
if res.get("code") == "BAD_EVENT_QUEUE_ID" or res["msg"].startswith(
"Bad event queue id:"
):
# Our event queue went away, probably because
# we were asleep or the server restarted
# abnormally. We may have missed some
@ -693,21 +791,20 @@ class Client:
last_event_id = max(last_event_id, int(event['id']))
callback(event)
def call_on_each_message(self, callback: Callable[[Dict[str, Any]], None], **kwargs: object) -> None:
def call_on_each_message(
self, callback: Callable[[Dict[str, Any]], None], **kwargs: object
) -> None:
def event_callback(event: Dict[str, Any]) -> None:
if event['type'] == 'message':
callback(event['message'])
self.call_on_each_event(event_callback, ['message'], None, **kwargs)
def get_messages(self, message_filters: Dict[str, Any]) -> Dict[str, Any]:
'''
See examples/get-messages for example usage
'''
return self.call_endpoint(
url='messages',
method='GET',
request=message_filters
)
return self.call_endpoint(url='messages', method='GET', request=message_filters)
def check_messages_match_narrow(self, **request: Dict[str, Any]) -> Dict[str, Any]:
@ -719,20 +816,13 @@ class Client:
)
{'result': 'success', 'msg': '', 'messages': [{...}, {...}]}
'''
return self.call_endpoint(
url='messages/matches_narrow',
method='GET',
request=request
)
return self.call_endpoint(url='messages/matches_narrow', method='GET', request=request)
def get_raw_message(self, message_id: int) -> Dict[str, str]:
'''
See examples/get-raw-message for example usage
'''
return self.call_endpoint(
url='messages/{}'.format(message_id),
method='GET'
)
return self.call_endpoint(url='messages/{}'.format(message_id), method='GET')
def send_message(self, message_data: Dict[str, Any]) -> Dict[str, Any]:
'''
@ -747,10 +837,7 @@ class Client:
'''
See examples/upload-file for example usage.
'''
return self.call_endpoint(
url='user_uploads',
files=[file]
)
return self.call_endpoint(url='user_uploads', files=[file])
def get_attachments(self) -> Dict[str, Any]:
'''
@ -759,10 +846,7 @@ class Client:
>>> client.get_attachments()
{'result': 'success', 'msg': '', 'attachments': [{...}, {...}]}
'''
return self.call_endpoint(
url='attachments',
method='GET'
)
return self.call_endpoint(url='attachments', method='GET')
def update_message(self, message_data: Dict[str, Any]) -> Dict[str, Any]:
'''
@ -778,20 +862,13 @@ class Client:
'''
See examples/delete-message for example usage.
'''
return self.call_endpoint(
url='messages/{}'.format(message_id),
method='DELETE'
)
return self.call_endpoint(url='messages/{}'.format(message_id), method='DELETE')
def update_message_flags(self, update_data: Dict[str, Any]) -> Dict[str, Any]:
'''
See examples/update-flags for example usage.
'''
return self.call_endpoint(
url='messages/flags',
method='POST',
request=update_data
)
return self.call_endpoint(url='messages/flags', method='POST', request=update_data)
def mark_all_as_read(self) -> Dict[str, Any]:
'''
@ -838,10 +915,7 @@ class Client:
'''
See examples/message-history for example usage.
'''
return self.call_endpoint(
url='messages/{}/history'.format(message_id),
method='GET'
)
return self.call_endpoint(url='messages/{}/history'.format(message_id), method='GET')
def add_reaction(self, reaction_data: Dict[str, Any]) -> Dict[str, Any]:
'''
@ -883,10 +957,7 @@ class Client:
'''
See examples/realm-emoji for example usage.
'''
return self.call_endpoint(
url='realm/emoji',
method='GET'
)
return self.call_endpoint(url='realm/emoji', method='GET')
def upload_custom_emoji(self, emoji_name: str, file_obj: IO[Any]) -> Dict[str, Any]:
'''
@ -896,9 +967,7 @@ class Client:
{'result': 'success', 'msg': ''}
'''
return self.call_endpoint(
'realm/emoji/{}'.format(emoji_name),
method='POST',
files=[file_obj]
'realm/emoji/{}'.format(emoji_name), method='POST', files=[file_obj]
)
def delete_custom_emoji(self, emoji_name: str) -> Dict[str, Any]:
@ -1053,7 +1122,7 @@ class Client:
self,
event_types: Optional[Iterable[str]] = None,
narrow: Optional[List[List[str]]] = None,
**kwargs: object
**kwargs: object,
) -> Dict[str, Any]:
'''
Example usage:
@ -1067,11 +1136,7 @@ class Client:
if narrow is None:
narrow = []
request = dict(
event_types=event_types,
narrow=narrow,
**kwargs
)
request = dict(event_types=event_types, narrow=narrow, **kwargs)
return self.call_endpoint(
url='register',
@ -1246,11 +1311,7 @@ class Client:
for key, value in request.items():
request[key] = json.dumps(value)
return self.call_endpoint(
url='users/{}'.format(user_id),
method='PATCH',
request=request
)
return self.call_endpoint(url='users/{}'.format(user_id), method='PATCH', request=request)
def get_users(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
'''
@ -1273,21 +1334,14 @@ class Client:
'''
See examples/alert-words for example usage.
'''
return self.call_endpoint(
url='users/me/alert_words',
method='GET'
)
return self.call_endpoint(url='users/me/alert_words', method='GET')
def add_alert_words(self, alert_words: List[str]) -> Dict[str, Any]:
'''
See examples/alert-words for example usage.
'''
return self.call_endpoint(
url='users/me/alert_words',
method='POST',
request={
'alert_words': alert_words
}
url='users/me/alert_words', method='POST', request={'alert_words': alert_words}
)
def remove_alert_words(self, alert_words: List[str]) -> Dict[str, Any]:
@ -1295,11 +1349,7 @@ class Client:
See examples/alert-words for example usage.
'''
return self.call_endpoint(
url='users/me/alert_words',
method='DELETE',
request={
'alert_words': alert_words
}
url='users/me/alert_words', method='DELETE', request={'alert_words': alert_words}
)
def get_subscriptions(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
@ -1313,33 +1363,29 @@ class Client:
)
def list_subscriptions(self, request: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
logger.warning("list_subscriptions() is deprecated."
" Please use get_subscriptions() instead.")
logger.warning(
"list_subscriptions() is deprecated." " Please use get_subscriptions() instead."
)
return self.get_subscriptions(request)
def add_subscriptions(self, streams: Iterable[Dict[str, Any]], **kwargs: Any) -> Dict[str, Any]:
'''
See examples/subscribe for example usage.
'''
request = dict(
subscriptions=streams,
**kwargs
)
request = dict(subscriptions=streams, **kwargs)
return self.call_endpoint(
url='users/me/subscriptions',
request=request,
)
def remove_subscriptions(self, streams: Iterable[str],
principals: Union[Sequence[str], Sequence[int]] = []) -> Dict[str, Any]:
def remove_subscriptions(
self, streams: Iterable[str], principals: Union[Sequence[str], Sequence[int]] = []
) -> Dict[str, Any]:
'''
See examples/unsubscribe for example usage.
'''
request = dict(
subscriptions=streams,
principals=principals
)
request = dict(subscriptions=streams, principals=principals)
return self.call_endpoint(
url='users/me/subscriptions',
method='DELETE',
@ -1363,12 +1409,12 @@ class Client:
See examples/mute-topic for example usage.
'''
return self.call_endpoint(
url='users/me/subscriptions/muted_topics',
method='PATCH',
request=request
url='users/me/subscriptions/muted_topics', method='PATCH', request=request
)
def update_subscription_settings(self, subscription_data: List[Dict[str, Any]]) -> Dict[str, Any]:
def update_subscription_settings(
self, subscription_data: List[Dict[str, Any]]
) -> Dict[str, Any]:
'''
Example usage:
@ -1387,7 +1433,7 @@ class Client:
return self.call_endpoint(
url='users/me/subscriptions/properties',
method='POST',
request={'subscription_data': subscription_data}
request={'subscription_data': subscription_data},
)
def update_notification_settings(self, notification_settings: Dict[str, Any]) -> Dict[str, Any]:
@ -1422,10 +1468,7 @@ class Client:
'''
See examples/get-stream-topics for example usage.
'''
return self.call_endpoint(
url='users/me/{}/topics'.format(stream_id),
method='GET'
)
return self.call_endpoint(url='users/me/{}/topics'.format(stream_id), method='GET')
def get_user_groups(self) -> Dict[str, Any]:
'''
@ -1483,7 +1526,9 @@ class Client:
method='DELETE',
)
def update_user_group_members(self, user_group_id: int, group_data: Dict[str, Any]) -> Dict[str, Any]:
def update_user_group_members(
self, user_group_id: int, group_data: Dict[str, Any]
) -> Dict[str, Any]:
'''
Example usage:
@ -1577,11 +1622,7 @@ class Client:
})
{'result': 'success', 'msg': ''}
'''
return self.call_endpoint(
url='typing',
method='POST',
request=request
)
return self.call_endpoint(url='typing', method='POST', request=request)
def move_topic(
self,
@ -1592,7 +1633,7 @@ class Client:
message_id: Optional[int] = None,
propagate_mode: str = 'change_all',
notify_old_topic: bool = True,
notify_new_topic: bool = True
notify_new_topic: bool = True,
) -> Dict[str, Any]:
'''
Move a topic from ``stream`` to ``new_stream``
@ -1622,26 +1663,28 @@ class Client:
if message_id is None:
if propagate_mode != 'change_all':
raise AttributeError('A message_id must be provided if '
'propagate_mode isn\'t "change_all"')
raise AttributeError(
'A message_id must be provided if ' 'propagate_mode isn\'t "change_all"'
)
# ask the server for the latest message ID in the topic.
result = self.get_messages({
result = self.get_messages(
{
'anchor': 'newest',
'narrow': [{'operator': 'stream', 'operand': stream},
{'operator': 'topic', 'operand': topic}],
'narrow': [
{'operator': 'stream', 'operand': stream},
{'operator': 'topic', 'operand': topic},
],
'num_before': 1,
'num_after': 0,
})
}
)
if result['result'] != 'success':
return result
if len(result['messages']) <= 0:
return {
'result': 'error',
'msg': 'No messages found in topic: "{}"'.format(topic)
}
return {'result': 'error', 'msg': 'No messages found in topic: "{}"'.format(topic)}
message_id = result['messages'][0]['id']
@ -1651,7 +1694,7 @@ class Client:
'propagate_mode': propagate_mode,
'topic': new_topic,
'send_notification_to_old_thread': notify_old_topic,
'send_notification_to_new_thread': notify_new_topic
'send_notification_to_new_thread': notify_new_topic,
}
return self.call_endpoint(
url='messages/{}'.format(message_id),
@ -1672,15 +1715,13 @@ class ZulipStream:
self.subject = subject
def write(self, content: str) -> None:
message = {"type": self.type,
"to": self.to,
"subject": self.subject,
"content": content}
message = {"type": self.type, "to": self.to, "subject": self.subject, "content": content}
self.client.send_message(message)
def flush(self) -> None:
pass
def hash_util_decode(string: str) -> str:
"""
Returns a decoded string given a hash_util_encode() [present in zulip/zulip's zerver/lib/url_encoding.py] encoded string.

View file

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

View file

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

View file

@ -29,11 +29,15 @@ options = parser.parse_args()
client = zulip.init_from_options(options)
print(client.update_stream({
print(
client.update_stream(
{
'stream_id': options.stream_id,
'description': quote(options.description),
'new_name': quote(options.new_name),
'is_private': options.private,
'is_announcement_only': options.announcement_only,
'history_public_to_subscribers': options.history_public_to_subscribers
}))
'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.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('--filename', default='history.json', help="The file name to store the fetched \
history.\n Default 'history.json'")
parser.add_argument(
'--filename',
default='history.json',
help="The file name to store the fetched \
history.\n Default 'history.json'",
)
options = parser.parse_args()
client = zulip.init_from_options(options)
@ -33,7 +37,7 @@ request = {
'num_after': 1000,
'narrow': narrow,
'client_gravatar': False,
'apply_markdown': False
'apply_markdown': False,
}
all_messages = [] # type: List[Dict[str, Any]]

View file

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

View file

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

View file

@ -19,9 +19,11 @@ options = parser.parse_args()
client = zulip.init_from_options(options)
def print_event(event: Dict[str, Any]) -> None:
print(event)
# 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
# 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)
def print_message(message: Dict[str, Any]) -> None:
print(message)
# This is a blocking call, and will continuously poll for new messages
client.call_on_each_message(print_message)

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import zulip
class StringIO(_StringIO):
name = '' # https://github.com/python/typeshed/issues/598
usage = """upload-file [options]
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
streams_to_cancel = ['development help']
def get_watchlist() -> List[Any]:
storage = client.get_storage()
return list(storage['storage'].values())
def set_watchlist(watchlist: List[str]) -> None:
client.update_storage({'storage': dict(enumerate(watchlist))})
def handle_event(event: Dict[str, Any]) -> None:
try:
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:
watchlist.remove(event['message']['sender_email'])
if stream not in streams_to_cancel:
client.send_message({
client.send_message(
{
'type': 'private',
'to': event['message']['sender_email'],
'content': welcome_text.format(event['message']['sender_short_name'])
})
'content': welcome_text.format(event['message']['sender_short_name']),
}
)
set_watchlist(watchlist)
return
except Exception as err:
@ -89,5 +94,6 @@ def start_event_handler() -> None:
print("Starting event handler...")
client.call_on_each_event(handle_event, event_types=['realm_user', 'message'])
client = zulip.Client()
start_event_handler()

View file

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

View file

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

View file

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

View file

@ -8,24 +8,26 @@ class TestBaremetricsBot(BotTestCase, DefaultTests):
bot_name = "baremetrics"
def test_bot_responds_to_empty_message(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \
patch('requests.get'):
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
self.verify_reply('', 'No Command Specified')
def test_help_query(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \
patch('requests.get'):
self.verify_reply('help', '''
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
self.verify_reply(
'help',
'''
This bot gives updates about customer behavior, financial performance, and analytics
for an organization using the Baremetrics Api.\n
Enter `list-commands` to show the list of available commands.
Version 1.0
''')
''',
)
def test_list_commands_command(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \
patch('requests.get'):
self.verify_reply('list-commands', '**Available Commands:** \n'
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
self.verify_reply(
'list-commands',
'**Available Commands:** \n'
' - help : Display bot info\n'
' - list-commands : Display the list of available commands\n'
' - account-info : Display the account info\n'
@ -35,41 +37,54 @@ class TestBaremetricsBot(BotTestCase, DefaultTests):
' - list-subscriptions <source_id> : List the subscriptions in the '
'source\n'
' - create-plan <source_id> <oid> <name> <currency> <amount> <interval> '
'<interval_count> : Create a plan in the given source\n')
'<interval_count> : Create a plan in the given source\n',
)
def test_account_info_command(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}):
with self.mock_http_conversation('account_info'):
self.verify_reply('account-info', '**Your account information:**\nId: 376418\nCompany: NA\nDefault '
'Currency: United States Dollar')
self.verify_reply(
'account-info',
'**Your account information:**\nId: 376418\nCompany: NA\nDefault '
'Currency: United States Dollar',
)
def test_list_sources_command(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}):
with self.mock_http_conversation('list_sources'):
self.verify_reply('list-sources', '**Listing sources:** \n1.ID: 5f7QC5NC0Ywgcu\nProvider: '
'baremetrics\nProvider ID: None\n\n')
self.verify_reply(
'list-sources',
'**Listing sources:** \n1.ID: 5f7QC5NC0Ywgcu\nProvider: '
'baremetrics\nProvider ID: None\n\n',
)
def test_list_plans_command(self) -> None:
r = '**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' \
r = (
'**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'
)
with self.mock_config_info({'api_key': 'TEST'}):
with self.mock_http_conversation('list_plans'):
self.verify_reply('list-plans TEST', r)
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'
)
with self.mock_config_info({'api_key': 'TEST'}):
with self.mock_http_conversation('list_customers'):
self.verify_reply('list-customers TEST', r)
def test_list_subscriptions_command(self) -> None:
r = '**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' \
r = (
'**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'
)
with self.mock_config_info({'api_key': 'TEST'}):
with self.mock_http_conversation('list_subscriptions'):
@ -84,34 +99,30 @@ class TestBaremetricsBot(BotTestCase, DefaultTests):
bot_test_instance.initialize(StubBotHandler())
def test_invalid_command(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \
patch('requests.get'):
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
self.verify_reply('abcd', 'Invalid Command.')
def test_missing_params(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \
patch('requests.get'):
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
self.verify_reply('list-plans', 'Missing Params.')
def test_key_error(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \
patch('requests.get'):
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
with self.mock_http_conversation('test_key_error'):
self.verify_reply('list-plans TEST', 'Invalid Response From API.')
def test_create_plan_command(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \
patch('requests.get'):
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
with self.mock_http_conversation('create_plan'):
self.verify_reply('create-plan TEST 1 TEST USD 123 TEST 123', 'Plan Created.')
def test_create_plan_error_command(self) -> None:
with self.mock_config_info({'api_key': 'TEST'}), \
patch('requests.get'):
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
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:
with self.mock_config_info({'api_key': 'TEST'}), \
patch('requests.get'):
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
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*]\
'''
def get_beeminder_response(message_content: str, config_info: Dict[str, str]) -> str:
username = config_info['username']
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':
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(',')
for i in range(len(message_pieces)):
message_pieces[i] = message_pieces[i].strip()
if (len(message_pieces) == 1):
payload = {
"value": message_pieces[0],
"auth_token": auth_token
}
elif (len(message_pieces) == 2):
if (message_pieces[1].isdigit()):
if len(message_pieces) == 1:
payload = {"value": message_pieces[0], "auth_token": auth_token}
elif len(message_pieces) == 2:
if message_pieces[1].isdigit():
payload = {
"daystamp": message_pieces[0],
"value": message_pieces[1],
"auth_token": auth_token
"auth_token": auth_token,
}
else:
payload = {
"value": message_pieces[0],
"comment": message_pieces[1],
"auth_token": auth_token
"auth_token": auth_token,
}
elif (len(message_pieces) == 3):
elif len(message_pieces) == 3:
payload = {
"daystamp": message_pieces[0],
"value": message_pieces[1],
"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 \
at syntax by: @mention-botname help"
@ -66,10 +66,14 @@ at syntax by: @mention-botname help"
if r.status_code == 401: # Handles case of invalid key and missing key
return "Error. Check your key!"
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:
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:
logging.exception(str(e))
return "Uh-Oh, couldn't process the request \
@ -87,7 +91,9 @@ class BeeminderHandler:
# Check for valid auth_token
auth_token = self.config_info['auth_token']
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:
bot_handler.quit('Invalid key!')
except ConnectionError as e:
@ -100,4 +106,5 @@ class BeeminderHandler:
response = get_beeminder_response(message['content'], self.config_info)
bot_handler.send_reply(message, response)
handler_class = BeeminderHandler

View file

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

View file

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

View file

@ -46,8 +46,10 @@ class ConnectFourBotHandler(GameAdapter):
def __init__(self) -> None:
game_name = 'Connect Four'
bot_name = 'connect_four'
move_help_message = '* To make your move during a game, type\n' \
move_help_message = (
'* To make your move during a game, type\n'
'```move <column-number>``` or ```<column-number>```'
)
move_regex = '(move ([1-7])$)|(([1-7])$)'
model = ConnectFourModel
gameMessageHandler = ConnectFourMessageHandler
@ -61,7 +63,7 @@ class ConnectFourBotHandler(GameAdapter):
model,
gameMessageHandler,
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],
]
self.current_board = self.blank_board
@ -27,10 +27,7 @@ class ConnectFourModel:
def get_column(self, col):
# We use this in tests.
return [
self.current_board[i][col]
for i in range(6)
]
return [self.current_board[i][col] for i in range(6)]
def validate_move(self, column_number):
if column_number < 0 or column_number > 6:
@ -76,8 +73,12 @@ class ConnectFourModel:
for row in range(0, 6):
for column in range(0, 4):
horizontal_sum = board[row][column] + board[row][column + 1] + \
board[row][column + 2] + board[row][column + 3]
horizontal_sum = (
board[row][column]
+ board[row][column + 1]
+ board[row][column + 2]
+ board[row][column + 3]
)
if horizontal_sum == -4:
return -1
elif horizontal_sum == 4:
@ -90,8 +91,12 @@ class ConnectFourModel:
for row in range(0, 3):
for column in range(0, 7):
vertical_sum = board[row][column] + board[row + 1][column] + \
board[row + 2][column] + board[row + 3][column]
vertical_sum = (
board[row][column]
+ board[row + 1][column]
+ board[row + 2][column]
+ board[row + 3][column]
)
if vertical_sum == -4:
return -1
elif vertical_sum == 4:
@ -106,8 +111,12 @@ class ConnectFourModel:
# Major Diagonl Sum
for row in range(0, 3):
for column in range(0, 4):
major_diagonal_sum = board[row][column] + board[row + 1][column + 1] + \
board[row + 2][column + 2] + board[row + 3][column + 3]
major_diagonal_sum = (
board[row][column]
+ board[row + 1][column + 1]
+ board[row + 2][column + 2]
+ board[row + 3][column + 3]
)
if major_diagonal_sum == -4:
return -1
elif major_diagonal_sum == 4:
@ -116,8 +125,12 @@ class ConnectFourModel:
# Minor Diagonal Sum
for row in range(3, 6):
for column in range(0, 4):
minor_diagonal_sum = board[row][column] + board[row - 1][column + 1] + \
board[row - 2][column + 2] + board[row - 3][column + 3]
minor_diagonal_sum = (
board[row][column]
+ board[row - 1][column + 1]
+ board[row - 2][column + 2]
+ board[row - 3][column + 3]
)
if minor_diagonal_sum == -4:
return -1
elif minor_diagonal_sum == 4:
@ -132,9 +145,11 @@ class ConnectFourModel:
if top_row_multiple != 0:
return 'draw'
winner = get_horizontal_wins(self.current_board) + \
get_vertical_wins(self.current_board) + \
get_diagonal_wins(self.current_board)
winner = (
get_horizontal_wins(self.current_board)
+ get_vertical_wins(self.current_board)
+ get_diagonal_wins(self.current_board)
)
if winner == 1:
return first_player

View file

@ -9,20 +9,19 @@ class TestConnectFourBot(BotTestCase, DefaultTests):
bot_name = 'connect_four'
def make_request_message(
self,
content: str,
user: str = 'foo@example.com',
user_name: str = 'foo'
self, content: str, user: str = 'foo@example.com', user_name: str = 'foo'
) -> Dict[str, str]:
message = dict(
sender_email=user,
content=content,
sender_full_name=user_name
)
message = dict(sender_email=user, content=content, sender_full_name=user_name)
return message
# 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
to BotTestCase.verify_dialog, but allows
@ -36,11 +35,7 @@ class TestConnectFourBot(BotTestCase, DefaultTests):
bot.handle_message(message, bot_handler)
responses = [
message
for (method, message)
in bot_handler.transcript
]
responses = [message for (method, message) in bot_handler.transcript]
first_response = responses[response_number]
self.assertEqual(expected_response, first_response['content'])
@ -73,7 +68,9 @@ class TestConnectFourBot(BotTestCase, DefaultTests):
self.verify_response('help', self.help_message(), 0)
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: \n\n\
:white_circle: :white_circle: :white_circle: :white_circle: \
@ -86,16 +83,18 @@ class TestConnectFourBot(BotTestCase, DefaultTests):
:white_circle: :white_circle: \n\n\
:blue_circle: :red_circle: :white_circle: :white_circle: :white_circle: \
:white_circle: :white_circle: '
)
bot, bot_handler = self._get_handlers()
self.assertEqual(bot.gameMessageHandler.parse_board(
self.almost_win_board), board)
self.assertEqual(bot.gameMessageHandler.parse_board(self.almost_win_board), board)
self.assertEqual(bot.gameMessageHandler.get_player_color(1), ':red_circle:')
self.assertEqual(
bot.gameMessageHandler.get_player_color(1), ':red_circle:')
self.assertEqual(bot.gameMessageHandler.alert_move_message(
'foo', 'move 6'), 'foo moved in column 6')
self.assertEqual(bot.gameMessageHandler.game_start_message(
), '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!')
bot.gameMessageHandler.alert_move_message('foo', 'move 6'), 'foo moved in column 6'
)
self.assertEqual(
bot.gameMessageHandler.game_start_message(),
'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!',
)
blank_board = [
[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],
]
almost_win_board = [
[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],
[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 = [
[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],
]
def test_connect_four_logic(self) -> None:
def confirmAvailableMoves(
good_moves: List[int],
bad_moves: List[int],
board: List[List[int]]
good_moves: List[int], bad_moves: List[int], board: List[List[int]]
) -> None:
connectFourModel.update_board(board)
@ -139,18 +139,16 @@ The first player to get 4 in a row wins!\n Good Luck!')
column_number: int,
token_number: int,
initial_board: List[List[int]],
final_board: List[List[int]]
final_board: List[List[int]],
) -> None:
connectFourModel.update_board(initial_board)
test_board = connectFourModel.make_move(
'move ' + str(column_number), token_number)
test_board = connectFourModel.make_move('move ' + str(column_number), token_number)
self.assertEqual(test_board, final_board)
def confirmGameOver(board: List[List[int]], result: str) -> None:
connectFourModel.update_board(board)
game_over = connectFourModel.determine_game_over(
['first_player', 'second_player'])
game_over = connectFourModel.determine_game_over(['first_player', 'second_player'])
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],
]
full_board = [
[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],
]
single_column_board = [
[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],
]
diagonal_board = [
[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, 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
# 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)
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],
[1, 1, 1, 1, 0, 0, 0]],
[[0, 0, 0, 1, 1, 1, 1],
[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, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]],
[[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 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],
[-1, -1, -1, -1, 0, 0, 0]],
[[0, 0, 0, -1, -1, -1, -1],
[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, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]],
[[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 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],
],
],
]
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],
[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, 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, 1, 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, 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, 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, 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],
[-1, 0, 0, 0, 0, 0, 0]],
[[0, 0, 0, 0, 0, 0, -1],
[-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, 0],
[0, 0, 0, 0, 0, 0, 0]],
[[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, -1, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0]]
]
[0, 0, 0, 0, 0, 0, 0],
],
],
]
major_diagonal_win_boards = [
[
[[1, 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, 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, 1, 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, 0, 0, 0, 0, 0, 1],
],
[
[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, 1, 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],
[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, 0, 0, 0]],
[[0, 0, 0, 0, 0, 0, 0],
[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, -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, -1],
],
[
[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, -1, 0],
[0, 0, 0, 0, 0, 0, 0]]
]
[0, 0, 0, 0, 0, 0, 0],
],
],
]
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, 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],
[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, 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, 1, 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, 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],
[-1, 0, 0, 0, 0, 0, 0]],
[[0, 0, 0, 0, 0, 0, -1],
[-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, -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, -1, 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],
],
],
]
# 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
connectFourModel.update_board(blank_board)
self.assertEqual(connectFourModel.available_moves(),
[0, 1, 2, 3, 4, 5, 6])
self.assertEqual(connectFourModel.available_moves(), [0, 1, 2, 3, 4, 5, 6])
connectFourModel.update_board(single_column_board)
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(), [])
# Test Move Logic
confirmMove(1, 0, blank_board,
[[0, 0, 0, 0, 0, 0, 0],
confirmMove(
1,
0,
blank_board,
[
[0, 0, 0, 0, 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],
],
)
confirmMove(1, 1, blank_board,
[[0, 0, 0, 0, 0, 0, 0],
confirmMove(
1,
1,
blank_board,
[
[0, 0, 0, 0, 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],
],
)
confirmMove(1, 0, diagonal_board,
[[0, 0, 0, 0, 0, 0, 1],
confirmMove(
1,
0,
diagonal_board,
[
[0, 0, 0, 0, 0, 0, 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]])
[1, 1, 1, 1, 1, 1, 1],
],
)
confirmMove(2, 0, diagonal_board,
[[0, 0, 0, 0, 0, 0, 1],
confirmMove(
2,
0,
diagonal_board,
[
[0, 0, 0, 0, 0, 0, 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]])
[0, 1, 1, 1, 1, 1, 1],
],
)
confirmMove(3, 0, diagonal_board,
[[0, 0, 0, 0, 0, 0, 1],
confirmMove(
3,
0,
diagonal_board,
[
[0, 0, 0, 0, 0, 0, 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]])
[0, 1, 1, 1, 1, 1, 1],
],
)
confirmMove(4, 0, diagonal_board,
[[0, 0, 0, 0, 0, 0, 1],
confirmMove(
4,
0,
diagonal_board,
[
[0, 0, 0, 0, 0, 0, 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]])
[0, 1, 1, 1, 1, 1, 1],
],
)
confirmMove(5, 0, diagonal_board,
[[0, 0, 0, 0, 0, 0, 1],
confirmMove(
5,
0,
diagonal_board,
[
[0, 0, 0, 0, 0, 0, 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]])
[0, 1, 1, 1, 1, 1, 1],
],
)
confirmMove(6, 0, diagonal_board,
[[0, 0, 0, 0, 0, 1, 1],
confirmMove(
6,
0,
diagonal_board,
[
[0, 0, 0, 0, 0, 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]])
[0, 1, 1, 1, 1, 1, 1],
],
)
# Test Game Over Logic:
confirmGameOver(blank_board, '')

View file

@ -15,14 +15,17 @@ def is_float(value: Any) -> bool:
except ValueError:
return False
# Rounds the number 'x' to 'digits' significant digits.
# A normal 'round()' would round the number to an absolute amount of
# fractional decimals, e.g. 0.00045 would become 0.0.
# 'round_to()' rounds only the digits that are not 0.
# 0.00045 would then become 0.0005.
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:
'''
@ -49,6 +52,7 @@ class ConverterHandler:
bot_response = get_bot_converter_response(message, bot_handler)
bot_handler.send_reply(message, bot_response)
def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler) -> str:
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():
if unit_from.startswith(key):
exponent += exp
unit_from = unit_from[len(key):]
unit_from = unit_from[len(key) :]
if unit_to.startswith(key):
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]
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]:
unit_from = unit_from.capitalize() if uf_to_std[2] == 'kelvin' else unit_from
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
@ -114,10 +123,11 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler)
number_res *= 10 ** exponent
number_res = round_to(number_res, 7)
results.append('{} {} = {} {}'.format(number,
words[convert_index + 2],
number_res,
words[convert_index + 3]))
results.append(
'{} {} = {} {}'.format(
number, words[convert_index + 2], number_res, words[convert_index + 3]
)
)
else:
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
handler_class = ConverterHandler

View file

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

View file

@ -2,7 +2,8 @@
# 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
# the base unit in the last parameter.
UNITS = {'bit': [0, 1, 'bit'],
UNITS = {
'bit': [0, 1, 'bit'],
'byte': [0, 8, 'bit'],
'cubic-centimeter': [0, 0.000001, 'cubic-meter'],
'cubic-decimeter': [0, 0.001, 'cubic-meter'],
@ -42,9 +43,11 @@ UNITS = {'bit': [0, 1, 'bit'],
'square-mile': [0, 2589988.110336, 'square-meter'],
'are': [0, 100, 'square-meter'],
'hectare': [0, 10000, 'square-meter'],
'acre': [0, 4046.8564224, 'square-meter']}
'acre': [0, 4046.8564224, 'square-meter'],
}
PREFIXES = {'atto': -18,
PREFIXES = {
'atto': -18,
'femto': -15,
'pico': -12,
'nano': -9,
@ -59,9 +62,11 @@ PREFIXES = {'atto': -18,
'giga': 9,
'tera': 12,
'peta': 15,
'exa': 18}
'exa': 18,
}
ALIASES = {'a': 'are',
ALIASES = {
'a': 'are',
'ac': 'acre',
'c': 'celsius',
'cm': 'centimeter',
@ -112,9 +117,11 @@ ALIASES = {'a': 'are',
'y2': 'square-yard',
'y3': 'cubic-yard',
'y^2': 'square-yard',
'y^3': 'cubic-yard'}
'y^3': 'cubic-yard',
}
HELP_MESSAGE = ('Converter usage:\n'
HELP_MESSAGE = (
'Converter usage:\n'
'`@convert <number> <unit_from> <unit_to>`\n'
'Converts `number` in the unit <unit_from> to '
'the <unit_to> and prints the result\n'
@ -141,6 +148,7 @@ HELP_MESSAGE = ('Converter usage:\n'
'* `@convert 12 celsius fahrenheit`\n'
'* `@convert 0.002 kilomile millimeter`\n'
'* `@convert 31.5 square-mile ha`\n'
'* `@convert 56 g lb`\n')
'* `@convert 56 g lb`\n'
)
QUICK_HELP = 'Enter `@convert help` for help on using the converter.'

View file

@ -66,7 +66,9 @@ class DefineHandler:
# Show definitions line by line.
for d in definitions:
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:
response += self.REQUEST_ERROR_MESSAGE
@ -74,4 +76,5 @@ class DefineHandler:
return response
handler_class = DefineHandler

View file

@ -9,15 +9,18 @@ class TestDefineBot(BotTestCase, DefaultTests):
def test_bot(self) -> None:
# Only one type(noun) of word.
bot_response = ("**cat**:\n\n* (**noun**) a small domesticated carnivorous mammal "
bot_response = (
"**cat**:\n\n* (**noun**) a small domesticated carnivorous mammal "
"with soft fur, a short snout, and retractile claws. It is widely "
"kept as a pet or for catching mice, and many breeds have been "
"developed.\n&nbsp;&nbsp;their pet cat\n\n")
"developed.\n&nbsp;&nbsp;their pet cat\n\n"
)
with self.mock_http_conversation('test_single_type_word'):
self.verify_reply('cat', bot_response)
# Multi-type word.
bot_response = ("**help**:\n\n"
bot_response = (
"**help**:\n\n"
"* (**verb**) make it easier or possible for (someone) to do something by offering them one's services or resources.\n"
"&nbsp;&nbsp;they helped her with domestic chores\n\n\n"
"* (**verb**) serve someone with (food or drink).\n"
@ -27,7 +30,8 @@ class TestDefineBot(BotTestCase, DefaultTests):
"* (**noun**) the action of helping someone to do something.\n"
"&nbsp;&nbsp;I asked for help from my neighbours\n\n\n"
"* (**exclamation**) used as an appeal for urgent assistance.\n"
"&nbsp;&nbsp;Help! I'm drowning!\n\n")
"&nbsp;&nbsp;Help! I'm drowning!\n\n"
)
with self.mock_http_conversation('test_multi_type_word'):
self.verify_reply('help', bot_response)
@ -49,8 +53,5 @@ class TestDefineBot(BotTestCase, DefaultTests):
self.verify_reply('', bot_response)
def test_connection_error(self) -> None:
with patch('requests.get', side_effect=Exception), \
patch('logging.exception'):
self.verify_reply(
'aeroplane',
'**aeroplane**:\nCould not load definition.')
with patch('requests.get', side_effect=Exception), patch('logging.exception'):
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.
'''
def get_bot_result(message_content: str, config: Dict[str, str], sender_id: str) -> str:
if message_content.strip() == '' or message_content.strip() == 'help':
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_json = json.loads(res_str)
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 'alternateResult' in res_json.keys():
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))
return 'Error. {}.'.format(str(e))
class DialogFlowHandler:
'''
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'])
bot_handler.send_reply(message, result)
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
class MockHttplibRequest():
class MockHttplibRequest:
def __init__(self, response: str) -> None:
self.response = response
def read(self) -> ByteString:
return json.dumps(self.response).encode()
class MockTextRequest():
class MockTextRequest:
def __init__(self) -> None:
self.session_id = ""
self.query = ""
@ -22,6 +23,7 @@ class MockTextRequest():
def getresponse(self) -> MockHttplibRequest:
return MockHttplibRequest(self.response)
@contextmanager
def mock_dialogflow(test_name: str, bot_name: str) -> Iterator[None]:
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
yield
class TestDialogFlowBot(BotTestCase, DefaultTests):
bot_name = 'dialogflow'
def _test(self, test_name: str, message: str, response: str) -> None:
with self.mock_config_info({'key': 'abcdefg', 'bot_info': 'bot info foo bar'}), \
mock_dialogflow(test_name, 'dialogflow'):
with self.mock_config_info(
{'key': 'abcdefg', 'bot_info': 'bot info foo bar'}
), mock_dialogflow(test_name, 'dialogflow'):
self.verify_reply(message, response)
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})"
class DropboxHandler:
'''
This bot allows you to easily share, search and upload files
@ -28,6 +29,7 @@ class DropboxHandler:
msg = dbx_command(self.client, command)
bot_handler.send_reply(message, msg)
def get_help() -> str:
return '''
Example commands:
@ -44,6 +46,7 @@ def get_help() -> str:
```
'''
def get_usage_examples() -> str:
return '''
Usage:
@ -61,15 +64,17 @@ def get_usage_examples() -> str:
```
'''
REGEXES = dict(
command='(ls|mkdir|read|rm|write|search|usage|help)',
path=r'(\S+)',
optional_path=r'(\S*)',
some_text='(.+?)',
folder=r'?(?:--fd (\S+))?',
max_results=r'?(?:--mr (\d+))?'
max_results=r'?(?:--mr (\d+))?',
)
def get_commands() -> Dict[str, Tuple[Any, List[str]]]:
return {
'help': (dbx_help, ['command']),
@ -83,12 +88,13 @@ def get_commands() -> Dict[str, Tuple[Any, List[str]]]:
'usage': (dbx_usage, []),
}
def dbx_command(client: Any, cmd: str) -> str:
cmd = cmd.strip()
if cmd == 'help':
return get_help()
cmd_name = cmd.split()[0]
cmd_args = cmd[len(cmd_name):].strip()
cmd_args = cmd[len(cmd_name) :].strip()
commands = get_commands()
if cmd_name not in commands:
return 'ERROR: unrecognized command\n' + get_help()
@ -102,6 +108,7 @@ def dbx_command(client: Any, cmd: str) -> str:
else:
return 'ERROR: ' + syntax_help(cmd_name)
def syntax_help(cmd_name: str) -> str:
commands = get_commands()
f, arg_names = commands[cmd_name]
@ -112,23 +119,29 @@ def syntax_help(cmd_name: str) -> str:
cmd = cmd_name
return 'syntax: {}'.format(cmd)
def dbx_help(client: Any, cmd_name: str) -> str:
return syntax_help(cmd_name)
def dbx_usage(client: Any) -> str:
return get_usage_examples()
def dbx_mkdir(client: Any, fn: str) -> str:
fn = '/' + fn # foo/boo -> /foo/boo
try:
result = client.files_create_folder(fn)
msg = "CREATED FOLDER: " + URL.format(name=result.name, path=result.path_lower)
except Exception:
msg = "Please provide a correct folder path and name.\n"\
msg = (
"Please provide a correct folder path and name.\n"
"Usage: `mkdir <foldername>` to create a folder."
)
return msg
def dbx_ls(client: Any, fn: str) -> str:
if fn != '':
fn = '/' + fn
@ -144,12 +157,15 @@ def dbx_ls(client: Any, fn: str) -> str:
msg = '`No files available`'
except Exception:
msg = "Please provide a correct folder path\n"\
"Usage: `ls <foldername>` to list folders in directory\n"\
msg = (
"Please provide a correct folder path\n"
"Usage: `ls <foldername>` to list folders in directory\n"
"or simply `ls` for listing folders in the root directory"
)
return msg
def dbx_rm(client: Any, fn: str) -> str:
fn = '/' + fn
@ -157,10 +173,13 @@ def dbx_rm(client: Any, fn: str) -> str:
result = client.files_delete(fn)
msg = "DELETED File/Folder : " + URL.format(name=result.name, path=result.path_lower)
except Exception:
msg = "Please provide a correct folder path and name.\n"\
msg = (
"Please provide a correct folder path and name.\n"
"Usage: `rm <foldername>` to delete a folder in root directory."
)
return msg
def dbx_write(client: Any, fn: str, content: str) -> str:
fn = '/' + fn
@ -172,6 +191,7 @@ def dbx_write(client: Any, fn: str, content: str) -> str:
return msg
def dbx_read(client: Any, fn: str) -> str:
fn = '/' + fn
@ -179,10 +199,13 @@ def dbx_read(client: Any, fn: str) -> str:
result = client.files_download(fn)
msg = "**{}** :\n{}".format(result[0].name, result[1].text)
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
def dbx_search(client: Any, query: str, folder: str, max_results: str) -> str:
if folder is None:
folder = ''
@ -201,17 +224,22 @@ def dbx_search(client: Any, query: str, folder: str, max_results: str) -> str:
msg = '\n'.join(msg_list)
except Exception:
msg = "Usage: `search <foldername> query --mr 10 --fd <folderName>`\n"\
"Note:`--mr <int>` is optional and is used to specify maximun results.\n"\
msg = (
"Usage: `search <foldername> query --mr 10 --fd <folderName>`\n"
"Note:`--mr <int>` is optional and is used to specify maximun results.\n"
" `--fd <folderName>` to search in specific folder."
)
if msg == '':
msg = "No files/folders found matching your query.\n"\
"For file name searching, the last token is used for prefix matching"\
msg = (
"No files/folders found matching your query.\n"
"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
def dbx_share(client: Any, fn: str):
fn = '/' + fn
try:
@ -222,4 +250,5 @@ def dbx_share(client: Any, fn: str):
return msg
handler_class = DropboxHandler

View file

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

View file

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

View file

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

View file

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

View file

@ -15,20 +15,28 @@ class TestFileUploaderBot(BotTestCase, DefaultTests):
@patch('pathlib.Path.is_file', return_value=True)
def test_file_upload_failed(self, is_file: Mock, resolve: Mock) -> None:
server_reply = dict(result='', msg='error')
with patch('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()))
with patch(
'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())
)
@patch('pathlib.Path.resolve', return_value=Path('/file.txt'))
@patch('pathlib.Path.is_file', return_value=True)
def test_file_upload_success(self, is_file: Mock, resolve: Mock) -> None:
server_reply = dict(result='success', uri='https://file/uri')
with patch('zulip_bots.test_lib.StubBotHandler.upload_file_from_path',
return_value=server_reply):
with patch(
'zulip_bots.test_lib.StubBotHandler.upload_file_from_path', return_value=server_reply
):
self.verify_reply('file.txt', '[file.txt](https://file/uri)')
def test_help(self):
self.verify_reply('help',
('Use this bot with any of the following commands:'
self.verify_reply(
'help',
(
'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'))
'\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']:
return user['id']
# 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)
# 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"
return (None, error)
# 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
# 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']
payload = {
'token': token
}
payload = {'token': token}
users, error = make_flock_request(USERS_LIST_URL, payload)
if users is None:
return (None, error)
@ -53,6 +55,7 @@ def get_recipient_id(recipient_name: str, config: Dict[str, str]) -> Tuple[Optio
else:
return (recipient_id, None)
# This handles the message sending work.
def get_flock_response(content: str, config: Dict[str, str]) -> str:
token = config['token']
@ -67,11 +70,7 @@ def get_flock_response(content: str, config: Dict[str, str]) -> str:
if len(str(recipient_id)) > 30:
return "Found user is invalid."
payload = {
'to': recipient_id,
'text': message,
'token': token
}
payload = {'to': recipient_id, 'text': message, 'token': token}
res, error = make_flock_request(SEND_MESSAGE_URL, payload)
if res is None:
return error
@ -81,6 +80,7 @@ def get_flock_response(content: str, config: Dict[str, str]) -> str:
else:
return "Message sending failed :slightly_frowning_face:. Please try again."
def get_flock_bot_response(content: str, config: Dict[str, str]) -> None:
content = content.strip()
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)
return result
class FlockHandler:
'''
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)
bot_handler.send_reply(message, response)
handler_class = FlockHandler

View file

@ -9,11 +9,7 @@ class TestFlockBot(BotTestCase, DefaultTests):
bot_name = "flock"
normal_config = {"token": "12345"}
message_config = {
"token": "12345",
"text": "Ricky: test message",
"to": "u:somekey"
}
message_config = {"token": "12345", "text": "Ricky: test message", "to": "u:somekey"}
help_message = '''
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)
def test_fetch_id_connection_error(self) -> None:
with self.mock_config_info(self.normal_config), \
patch('requests.get', side_effect=ConnectionError()), \
patch('logging.exception'):
self.verify_reply('tyler: Hey tyler', "Uh-Oh, couldn\'t process the request \
right now.\nPlease try again later")
with self.mock_config_info(self.normal_config), patch(
'requests.get', side_effect=ConnectionError()
), patch('logging.exception'):
self.verify_reply(
'tyler: Hey tyler',
"Uh-Oh, couldn\'t process the request \
right now.\nPlease try again later",
)
def test_response_connection_error(self) -> None:
with self.mock_config_info(self.message_config), \
patch('requests.get', side_effect=ConnectionError()), \
patch('logging.exception'):
self.verify_reply('Ricky: test message', "Uh-Oh, couldn\'t process the request \
right now.\nPlease try again later")
with self.mock_config_info(self.message_config), patch(
'requests.get', side_effect=ConnectionError()
), patch('logging.exception'):
self.verify_reply(
'Ricky: test message',
"Uh-Oh, couldn\'t process the request \
right now.\nPlease try again later",
)
def test_no_recipient_found(self) -> None:
bot_response = "No user found. Make sure you typed it correctly."
with self.mock_config_info(self.normal_config), \
self.mock_http_conversation('test_no_recipient_found'):
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
'test_no_recipient_found'
):
self.verify_reply('david: hello', bot_response)
def test_found_invalid_recipient(self) -> None:
bot_response = "Found user is invalid."
with self.mock_config_info(self.normal_config), \
self.mock_http_conversation('test_found_invalid_recipient'):
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
'test_found_invalid_recipient'
):
self.verify_reply('david: hello', bot_response)
@patch('zulip_bots.bots.flock.flock.get_recipient_id')
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"
get_recipient_id.return_value = ["u:userid", None]
with self.mock_config_info(self.normal_config), \
patch('requests.get', side_effect=ConnectionError()), \
patch('logging.exception'):
with self.mock_config_info(self.normal_config), patch(
'requests.get', side_effect=ConnectionError()
), patch('logging.exception'):
self.verify_reply('Rishabh: hi there', bot_response)
@patch('zulip_bots.bots.flock.flock.get_recipient_id')
def test_message_send_success(self, get_recipient_id: str) -> None:
bot_response = "Message sent."
get_recipient_id.return_value = ["u:userid", None]
with self.mock_config_info(self.normal_config), \
self.mock_http_conversation('test_message_send_success'):
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
'test_message_send_success'
):
self.verify_reply('Rishabh: hi there', bot_response)
@patch('zulip_bots.bots.flock.flock.get_recipient_id')
def test_message_send_failed(self, get_recipient_id: str) -> None:
bot_response = "Message sending failed :slightly_frowning_face:. Please try again."
get_recipient_id.return_value = ["u:invalid", None]
with self.mock_config_info(self.normal_config), \
self.mock_http_conversation('test_message_send_failed'):
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
'test_message_send_failed'
):
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:
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)
elif message['content'] == 'help':
bot_handler.send_reply(message, self.usage())
else:
bot_response = self.get_bot_followup_response(message)
bot_handler.send_message(dict(
bot_handler.send_message(
dict(
type='stream',
to=self.stream,
subject=message['sender_email'],
content=bot_response,
))
)
)
def get_bot_followup_response(self, message: Dict[str, str]) -> str:
original_content = message['content']
@ -53,4 +57,5 @@ class FollowupHandler:
return new_content
handler_class = FollowupHandler

View file

@ -31,7 +31,9 @@ class TestFollowUpBot(BotTestCase, DefaultTests):
self.assertEqual(response['to'], 'issue')
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'}):
self.verify_reply('', bot_response)

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
user_name: str = 'foo',
type: str = 'private',
stream: str = '',
subject: str = ''
subject: str = '',
) -> Dict[str, str]:
message = dict(
sender_email=user,
@ -38,7 +38,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
user_name: str = 'foo',
stream: str = '',
subject: str = '',
max_messages: int = 20
max_messages: int = 20,
) -> None:
'''
This function serves a similar purpose
@ -52,15 +52,12 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
_b, bot_handler = self._get_handlers()
type = 'private' if stream == '' else 'stream'
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.handle_message(message, bot_handler)
responses = [
message
for (method, message)
in bot_handler.transcript
]
responses = [message for (method, message) in bot_handler.transcript]
first_response = responses[response_number]
self.assertEqual(expected_response, first_response['content'])
self.assertLessEqual(len(responses), max_messages)
@ -70,12 +67,19 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
bot, bot_handler = self._get_handlers()
message = {
'sender_email': '{}@example.com'.format(name),
'sender_full_name': '{}'.format(name)
'sender_full_name': '{}'.format(name),
}
bot.add_user_to_cache(message)
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:
bot, bot_handler = self._get_handlers()
for p in players:
@ -84,8 +88,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
game_id = 'abc123'
if id != '':
game_id = id
instance = GameInstance(bot, False, subject,
game_id, players_emails, stream)
instance = GameInstance(bot, False, subject, game_id, players_emails, stream)
bot.instances.update({game_id: instance})
instance.turn = -1
instance.start()
@ -95,8 +98,9 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
bot = self.add_user_to_cache('foo')
bot.email = 'test-bot@example.com'
self.add_user_to_cache('test-bot', bot)
instance = GameInstance(bot, False, 'test game', 'abc123', [
'foo@example.com', 'test-bot@example.com'], 'test')
instance = GameInstance(
bot, False, 'test game', 'abc123', ['foo@example.com', 'test-bot@example.com'], 'test'
)
bot.instances.update({'abc123': instance})
instance.start()
return bot
@ -132,41 +136,55 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
self.verify_response('foo bar baz', self.help_message(), 0)
def test_exception_handling(self) -> None:
with patch('logging.exception'), \
patch('zulip_bots.game_handler.GameAdapter.command_quit',
side_effect=Exception):
with patch('logging.exception'), patch(
'zulip_bots.game_handler.GameAdapter.command_quit', side_effect=Exception
):
self.verify_response('quit', 'Error .', 0)
def test_not_in_game_messages(self) -> None:
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(
'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:
bot = self.add_user_to_cache('baz')
self.verify_response('start game with @**baz**',
'You\'ve sent an invitation to play foo test game with @**baz**', 1, bot=bot)
self.verify_response(
'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)
def test_start_game_with_email(self) -> None:
bot = self.add_user_to_cache('baz')
self.verify_response('start game with baz@example.com',
'You\'ve sent an invitation to play foo test game with @**baz**', 1, bot=bot)
self.verify_response(
'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)
def test_join_game_and_start_in_stream(self) -> None:
bot = self.add_user_to_cache('baz')
self.add_user_to_cache('foo', bot)
bot.invites = {
'abc': {
'stream': 'test',
'subject': 'test game',
'host': 'foo@example.com'
}
}
self.verify_response('join', '@**baz** has joined the game', 0, bot=bot,
stream='test', subject='test game', user_name='baz')
bot.invites = {'abc': {'stream': 'test', 'subject': 'test game', 'host': 'foo@example.com'}}
self.verify_response(
'join',
'@**baz** has joined the game',
0,
bot=bot,
stream='test',
subject='test game',
user_name='baz',
)
self.assertEqual(len(bot.instances.keys()), 1)
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!',
0,
stream='test',
subject='test game'
subject='test game',
)
def test_start_invite_game_in_stream(self) -> None:
@ -186,12 +204,19 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
2,
bot=bot,
stream='test',
subject='game test'
subject='game test',
)
def test_join_no_game(self) -> None:
self.verify_response('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)
self.verify_response(
'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:
bot = self.add_user_to_cache('baz')
@ -201,80 +226,96 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
'subject': '###private###',
'stream': 'games',
'host': 'foo@example.com',
'baz@example.com': 'p'
'baz@example.com': 'p',
}
}
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:
bot = self.add_user_to_cache('baz')
self.add_user_to_cache('foo', bot)
bot.invites = {
'abc': {
'subject': '###private###',
'host': 'foo@example.com',
'baz@example.com': 'p'
}
'abc': {'subject': '###private###', 'host': 'foo@example.com', 'baz@example.com': 'p'}
}
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:
bot = self.add_user_to_cache('foo')
bot.invites = {
'abc': {
'subject': '###private###',
'host': 'foo@example.com'
}
}
self.verify_response(
'quit', 'Game cancelled.\n**foo** quit.', 0, bot, 'foo')
bot.invites = {'abc': {'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:
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(
'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(
'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(
'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(
'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:
bot = self.add_user_to_cache('foo')
self.verify_response(
'register', 'Hello @**foo**. Thanks for registering!', 0, bot, 'foo')
self.verify_response('register', 'Hello @**foo**. Thanks for registering!', 0, bot, 'foo')
self.assertIn('foo@example.com', bot.user_cache.keys())
def test_no_active_invite_errors(self) -> None:
self.verify_response(
'accept', 'No active invites. Type `help` for commands.', 0)
self.verify_response(
'decline', 'No active invites. Type `help` for commands.', 0)
self.verify_response('accept', '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:
bot = self.add_user_to_cache('baz')
bot.min_players = 5
self.verify_response('start game with @**baz**',
'You must have at least 5 players to play.\nGame cancelled.', 0, bot=bot)
self.verify_response(
'start game with @**baz**',
'You must have at least 5 players to play.\nGame cancelled.',
0,
bot=bot,
)
bot.min_players = 2
bot.max_players = 1
self.verify_response('start game with @**baz**',
'The maximum number of players for this game is 1.', 0, bot=bot)
self.verify_response(
'start game with @**baz**',
'The maximum number of players for this game is 1.',
0,
bot=bot,
)
bot.max_players = 1
bot.invites = {
'abc': {
'stream': 'test',
'subject': 'test game',
'host': 'foo@example.com'
}
}
self.verify_response('join', 'This game is full.', 0, bot=bot,
stream='test', subject='test game', user_name='baz')
bot.invites = {'abc': {'stream': 'test', 'subject': 'test game', 'host': 'foo@example.com'}}
self.verify_response(
'join',
'This game is full.',
0,
bot=bot,
stream='test',
subject='test game',
user_name='baz',
)
def test_public_accept(self) -> None:
bot = self.add_user_to_cache('baz')
@ -284,70 +325,125 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
'stream': 'test',
'subject': 'test game',
'host': 'baz@example.com',
'foo@example.com': 'p'
'foo@example.com': 'p',
}
}
self.verify_response('accept', '@**foo** has accepted the invitation.',
0, bot=bot, stream='test', subject='test game')
self.verify_response(
'accept',
'@**foo** has accepted the invitation.',
0,
bot=bot,
stream='test',
subject='test game',
)
def test_start_game_with_computer(self) -> None:
self.verify_response('start game with @**test-bot**',
'Wait... That\'s me!', 4, stream='test', subject='test game')
self.verify_response(
'start game with @**test-bot**',
'Wait... That\'s me!',
4,
stream='test',
subject='test game',
)
def test_sent_by_bot(self) -> None:
with self.assertRaises(IndexError):
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:
bot = self.setup_game()
self.verify_response('forfeit', '**foo** forfeited!',
0, bot=bot, stream='test', subject='test game')
self.verify_response(
'forfeit', '**foo** forfeited!', 0, bot=bot, stream='test', subject='test game'
)
def test_draw(self) -> None:
bot = self.setup_game()
self.verify_response('draw', '**foo** has voted for a draw!\nType `draw` to accept',
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')
self.verify_response(
'draw',
'**foo** has voted for a draw!\nType `draw` to accept',
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:
bot = self.setup_game()
self.verify_response('move 3', '**foo** moved in column 3\n\nfoo\n\nIt\'s **baz**\'s (:red_circle:) turn.',
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')
self.verify_response(
'move 3',
'**foo** moved in column 3\n\nfoo\n\nIt\'s **baz**\'s (:red_circle:) turn.',
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:
bot = self.setup_game()
self.verify_response('move 5', 'It\'s **foo**\'s (:blue_circle:) turn.', 0,
bot=bot, stream='test', subject='test game', user_name='baz')
self.verify_response(
'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:
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.invites = {
'abcdefg': {
'host': 'bar@example.com',
'stream': 'test',
'subject': 'test game'
}
'abcdefg': {'host': 'bar@example.com', 'stream': 'test', 'subject': 'test game'}
}
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:
bot = self.add_user_to_cache('foo')
bot.invites = {
'abcdefg': {
'host': 'foo@example.com',
'stream': 'test',
'subject': 'test game'
'abcdefg': {'host': 'foo@example.com', 'stream': 'test', 'subject': 'test game'}
}
}
self.verify_response('start game', 'There is already a game in this stream.', 0,
bot=bot, stream='test', subject='test game', user_name='baz', max_messages=1)
self.verify_response(
'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:
# bot = self.setup_game()
@ -355,32 +451,46 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
# user_name='bar', stream='test', subject='test game', max_messages=1)
def test_unknown_user(self) -> None:
self.verify_response('start game with @**bar**',
'I don\'t know @**bar**. Tell them to say @**test-bot** register', 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)
self.verify_response(
'start game with @**bar**',
'I don\'t know @**bar**. Tell them to say @**test-bot** register',
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:
bot = self.add_user_to_cache('foo')
self.add_user_to_cache('baz', bot)
bot.invites = {
'abcdefg': {
'host': 'foo@example.com',
'baz@example.com': 'a'
}
}
bot.invites = {'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('baz@example.com'))
def test_move_help_message(self) -> None:
bot = self.setup_game()
self.verify_response('move 123', '* To make your move during a game, type\n```move <column-number>```',
0, bot=bot, stream='test', subject='test game')
self.verify_response(
'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:
bot = self.setup_game()
self.verify_response('move 9', 'Invalid Move.', 0,
bot=bot, stream='test', subject='test game', max_messages=2)
self.verify_response(
'move 9',
'Invalid Move.',
0,
bot=bot,
stream='test',
subject='test game',
max_messages=2,
)
def test_get_game_id_by_email(self) -> None:
bot = self.setup_game()
@ -389,9 +499,13 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
def test_game_over_and_leaderboard(self) -> None:
bot = self.setup_game()
bot.put_user_cache()
with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='foo@example.com'):
self.verify_response('move 3', '**foo** won! :tada:',
1, bot=bot, stream='test', subject='test game')
with patch(
'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
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\
Player | Games Won | Games Drawn | Games Lost | Total Games\n\
--- | --- | --- | --- | --- \n\
@ -402,27 +516,54 @@ Player | Games Won | Games Drawn | Games Lost | Total Games\n\
def test_current_turn_winner(self) -> None:
bot = self.setup_game()
with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='current turn'):
self.verify_response('move 3', '**foo** won! :tada:',
1, bot=bot, stream='test', subject='test game')
with patch(
'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
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:
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.',
0, 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')
self.verify_response(
'move 3',
'**foo** moved in column 3\n\nfoo\n\nIt\'s **test-bot**\'s (:red_circle:) turn.',
0,
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:
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'):
self.verify_response('move 5', 'You won! Nice!',
2, bot=bot, stream='test', subject='test game')
with patch(
'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
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()
with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='draw'):
self.verify_response('move 5', 'It was a draw! Well Played!',
2, bot=bot, stream='test', subject='test game')
with patch(
'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
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:
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',
'baz@example.com': 'a',
'stream': 'test',
'subject': 'test game'
'subject': 'test game',
}
}
self.assertEqual(bot.get_game_info('abcdefg'), {
self.assertEqual(
bot.get_game_info('abcdefg'),
{
'game_id': 'abcdefg',
'type': 'invite',
'stream': 'test',
'subject': 'test game',
'players': ['foo@example.com', 'baz@example.com']
})
'players': ['foo@example.com', 'baz@example.com'],
},
)
def test_parse_message(self) -> None:
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\
> 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()
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.
> **Game `abc123`**
> foo test game
> 2/2 players
> **[Join Game](/#narrow/stream/test/topic/test game)**''', 0, bot=bot, stream='test 2', subject='game 2')
self.verify_response('move 3', 'foo', 0, bot=bot,
stream='test 2', subject='game 2')
> **[Join Game](/#narrow/stream/test/topic/test game)**''',
0,
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:
bot = self.setup_game('abc123')
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.
> **Game `abcdefg`**
> foo test game
> 2/2 players
> **[Join Game](/#narrow/stream/test2/topic/test game 2)**''', 0, 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')
> **[Join Game](/#narrow/stream/test2/topic/test game 2)**''',
0,
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 = {
'foo bar baz': {
'host': 'foo@example.com',
'baz@example.com': 'a',
'stream': 'test',
'subject': 'test game'
'subject': 'test game',
}
}
bot.change_game_subject('foo bar baz', 'test2',
'game2', self.make_request_message('foo'))
bot.change_game_subject('foo bar baz', 'test2', 'game2', self.make_request_message('foo'))
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:
final_board = [[0, 1, 2],
[3, 4, 5],
[6, 7, 8]]
final_board = [[0, 1, 2], [3, 4, 5], [6, 7, 8]]
initial_board = [[8, 7, 6],
[5, 4, 3],
[2, 1, 0]]
initial_board = [[8, 7, 6], [5, 4, 3], [2, 1, 0]]
def __init__(self, board: Any = None) -> None:
if board is not None:
@ -41,7 +37,7 @@ class GameOfFifteenModel:
def won(self, board: Any) -> bool:
for i 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 True
@ -67,23 +63,26 @@ class GameOfFifteenModel:
if tile not in coordinates:
raise BadMoveException('You can only move tiles which exist in the board.')
i, j = coordinates[tile]
if (j-1) > -1 and board[i][j-1] == 0:
board[i][j-1] = tile
if (j - 1) > -1 and board[i][j - 1] == 0:
board[i][j - 1] = tile
board[i][j] = 0
elif (i-1) > -1 and board[i-1][j] == 0:
board[i-1][j] = tile
elif (i - 1) > -1 and board[i - 1][j] == 0:
board[i - 1][j] = tile
board[i][j] = 0
elif (j+1) < 3 and board[i][j+1] == 0:
board[i][j+1] = tile
elif (j + 1) < 3 and board[i][j + 1] == 0:
board[i][j + 1] = tile
board[i][j] = 0
elif (i+1) < 3 and board[i+1][j] == 0:
board[i+1][j] = tile
elif (i + 1) < 3 and board[i + 1][j] == 0:
board[i + 1][j] = tile
board[i][j] = 0
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:
return board
class GameOfFifteenMessageHandler:
tiles = {
@ -113,8 +112,11 @@ class GameOfFifteenMessageHandler:
return original_player + ' moved ' + tile
def game_start_message(self) -> str:
return ("Welcome to Game of Fifteen!"
"To make a move, type @-mention `move <tile1> <tile2> ...`")
return (
"Welcome to Game of Fifteen!"
"To make a move, type @-mention `move <tile1> <tile2> ...`"
)
class GameOfFifteenBotHandler(GameAdapter):
'''
@ -125,7 +127,9 @@ class GameOfFifteenBotHandler(GameAdapter):
def __init__(self) -> None:
game_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]+$'
model = GameOfFifteenModel
gameMessageHandler = GameOfFifteenMessageHandler
@ -145,4 +149,5 @@ class GameOfFifteenBotHandler(GameAdapter):
max_players=1,
)
handler_class = GameOfFifteenBotHandler

View file

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

View file

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

View file

@ -10,25 +10,28 @@ class TestGiphyBot(BotTestCase, DefaultTests):
# Test for bot response to empty message
def test_bot_responds_to_empty_message(self) -> None:
bot_response = '[Click to enlarge]' \
'(https://media0.giphy.com/media/ISumMYQyX4sSI/giphy.gif)' \
bot_response = (
'[Click to enlarge]'
'(https://media0.giphy.com/media/ISumMYQyX4sSI/giphy.gif)'
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)'
with self.mock_config_info({'key': '12345678'}), \
self.mock_http_conversation('test_random'):
)
with self.mock_config_info({'key': '12345678'}), self.mock_http_conversation('test_random'):
self.verify_reply('', bot_response)
def test_normal(self) -> None:
bot_response = '[Click to enlarge]' \
'(https://media4.giphy.com/media/3o6ZtpxSZbQRRnwCKQ/giphy.gif)' \
bot_response = (
'[Click to enlarge]'
'(https://media4.giphy.com/media/3o6ZtpxSZbQRRnwCKQ/giphy.gif)'
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)'
)
with self.mock_config_info({'key': '12345678'}), \
self.mock_http_conversation('test_normal'):
with self.mock_config_info({'key': '12345678'}), self.mock_http_conversation('test_normal'):
self.verify_reply('Hello', bot_response)
def test_no_result(self) -> None:
with self.mock_config_info({'key': '12345678'}), \
self.mock_http_conversation('test_no_result'):
with self.mock_config_info({'key': '12345678'}), self.mock_http_conversation(
'test_no_result'
):
self.verify_reply(
'world without zulip',
'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)
StubBotHandler()
with self.mock_http_conversation('test_403'):
self.validate_invalid_config({'key': '12345678'},
"This is likely due to an invalid key.\n")
self.validate_invalid_config(
{'key': '12345678'}, "This is likely due to an invalid key.\n"
)
def test_connection_error_when_validate_config(self) -> None:
error = ConnectionError()
@ -53,11 +57,12 @@ class TestGiphyBot(BotTestCase, DefaultTests):
self.validate_valid_config({'key': '12345678'})
def test_connection_error_while_running(self) -> None:
with self.mock_config_info({'key': '12345678'}), \
patch('requests.get', side_effect=[ConnectionError()]), \
patch('logging.exception'):
with self.mock_config_info({'key': '12345678'}), patch(
'requests.get', side_effect=[ConnectionError()]
), patch('logging.exception'):
self.verify_reply(
'world without chocolate',
'Uh oh, sorry :slightly_frowning_face:, I '
'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)
def usage(self) -> str:
return ("This plugin displays details on github issues and pull requests. "
return (
"This plugin displays details on github issues and pull requests. "
"To reference an issue or pull request usename mention the bot then "
"anytime in the message type its id, for example:\n"
"@**Github detail** #3212 zulip#3212 zulip/zulip#3212\n"
"The default owner is {} and the default repo is {}.".format(self.owner, self.repo))
"The default owner is {} and the default repo is {}.".format(self.owner, self.repo)
)
def format_message(self, details: Dict[str, Any]) -> str:
number = details['number']
@ -39,17 +41,24 @@ class GithubHandler:
description = details['body']
status = details['state'].title()
message_string = ('**[{owner}/{repo}#{id}]'.format(owner=owner, repo=repo, id=number),
message_string = (
'**[{owner}/{repo}#{id}]'.format(owner=owner, repo=repo, id=number),
'({link}) - {title}**\n'.format(title=title, link=link),
'Created by **[{author}](https://github.com/{author})**\n'.format(author=author),
'Status - **{status}**\n```quote\n{description}\n```'.format(status=status, description=description))
'Status - **{status}**\n```quote\n{description}\n```'.format(
status=status, description=description
),
)
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
try:
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:
logging.exception(str(e))
return None
@ -73,8 +82,7 @@ class GithubHandler:
return
# Capture owner, repo, id
issue_prs = list(re.finditer(
self.HANDLE_MESSAGE_REGEX, message['content']))
issue_prs = list(re.finditer(self.HANDLE_MESSAGE_REGEX, message['content']))
bot_messages = []
if len(issue_prs) > 5:
# We limit to 5 requests to prevent denial-of-service
@ -91,8 +99,11 @@ class GithubHandler:
details['repo'] = repo
bot_messages.append(self.format_message(details))
else:
bot_messages.append("Failed to find issue/pr: {owner}/{repo}#{id}"
.format(owner=owner, repo=repo, id=issue_pr.group(3)))
bot_messages.append(
"Failed to find issue/pr: {owner}/{repo}#{id}".format(
owner=owner, repo=repo, id=issue_pr.group(3)
)
)
else:
bot_messages.append("Failed to detect owner and repository name.")
if len(bot_messages) == 0:
@ -100,4 +111,5 @@ class GithubHandler:
bot_message = '\n'.join(bot_messages)
bot_handler.send_reply(message, bot_message)
handler_class = GithubHandler

View file

@ -23,15 +23,17 @@ class TestGithubDetailBot(BotTestCase, DefaultTests):
def test_issue(self) -> None:
request = 'zulip/zulip#5365'
bot_response = '**[zulip/zulip#5365](https://github.com/zulip/zulip/issues/5365)'\
' - frontend: Enable hot-reloading of CSS in development**\n'\
'Created by **[timabbott](https://github.com/timabbott)**\n'\
'Status - **Open**\n'\
'```quote\n'\
'There\'s strong interest among folks working on the frontend in being '\
'able to use the hot-reloading feature of webpack for managing our CSS.\r\n\r\n'\
'In order to do this, step 1 is to move our CSS minification pipeline '\
bot_response = (
'**[zulip/zulip#5365](https://github.com/zulip/zulip/issues/5365)'
' - frontend: Enable hot-reloading of CSS in development**\n'
'Created by **[timabbott](https://github.com/timabbott)**\n'
'Status - **Open**\n'
'```quote\n'
'There\'s strong interest among folks working on the frontend in being '
'able to use the hot-reloading feature of webpack for managing our CSS.\r\n\r\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_config_info(self.mock_config):
@ -39,18 +41,20 @@ class TestGithubDetailBot(BotTestCase, DefaultTests):
def test_pull_request(self) -> None:
request = 'zulip/zulip#5345'
bot_response = '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'\
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'\
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'\
'Status - **Open**\n```quote\nAn interaction bug (#4811) '\
'between our settings UI and the bootstrap modals breaks hotkey '\
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'\
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'\
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '\
'using bootstrap for the account settings modal and all other modals,'\
' replace with `Modal` class\r\n[] Add hotkey support for closing the'\
' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'\
bot_response = (
'**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'
'Status - **Open**\n```quote\nAn interaction bug (#4811) '
'between our settings UI and the bootstrap modals breaks hotkey '
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '
'using bootstrap for the account settings modal and all other modals,'
' replace with `Modal` class\r\n[] Add hotkey support for closing the'
' 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_config_info(self.mock_config):
self.verify_reply(request, bot_response)
@ -77,18 +81,22 @@ class TestGithubDetailBot(BotTestCase, DefaultTests):
def test_help_text(self) -> None:
request = 'help'
bot_response = 'This plugin displays details on github issues and pull requests. '\
'To reference an issue or pull request usename mention the bot then '\
'anytime in the message type its id, for example:\n@**Github detail** '\
'#3212 zulip#3212 zulip/zulip#3212\nThe default owner is zulip and '\
bot_response = (
'This plugin displays details on github issues and pull requests. '
'To reference an issue or pull request usename mention the bot then '
'anytime in the message type its id, for example:\n@**Github detail** '
'#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):
self.verify_reply(request, bot_response)
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'
)
bot_response = 'Please ask for <=5 links in any one request'
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:
request = '/#5345'
bot_response = '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'\
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'\
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'\
'Status - **Open**\n```quote\nAn interaction bug (#4811) '\
'between our settings UI and the bootstrap modals breaks hotkey '\
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'\
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'\
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '\
'using bootstrap for the account settings modal and all other modals,'\
' replace with `Modal` class\r\n[] Add hotkey support for closing the'\
' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'\
bot_response = (
'**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'
'Status - **Open**\n```quote\nAn interaction bug (#4811) '
'between our settings UI and the bootstrap modals breaks hotkey '
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '
'using bootstrap for the account settings modal and all other modals,'
' replace with `Modal` class\r\n[] Add hotkey support for closing the'
' 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_config_info(self.mock_config):
self.verify_reply(request, bot_response)
def test_owner_and_repo_specified_in_message(self) -> None:
request = 'zulip/zulip#5345'
bot_response = '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'\
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'\
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'\
'Status - **Open**\n```quote\nAn interaction bug (#4811) '\
'between our settings UI and the bootstrap modals breaks hotkey '\
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'\
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'\
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '\
'using bootstrap for the account settings modal and all other modals,'\
' replace with `Modal` class\r\n[] Add hotkey support for closing the'\
' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'\
bot_response = (
'**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'
'Status - **Open**\n```quote\nAn interaction bug (#4811) '
'between our settings UI and the bootstrap modals breaks hotkey '
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '
'using bootstrap for the account settings modal and all other modals,'
' replace with `Modal` class\r\n[] Add hotkey support for closing the'
' 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_config_info(self.empty_config):
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']:
continue
# a.text: The name of the page
result = {'url': "https://www.google.com{}".format(link),
'name': a.text}
result = {'url': "https://www.google.com{}".format(link), 'name': a.text}
results.append(result)
return results
def get_google_result(search_keywords: str) -> str:
help_message = "To use this bot, start messages with @mentioned-bot, \
followed by what you want to search for. If \
@ -56,13 +56,14 @@ def get_google_result(search_keywords: str) -> str:
else:
try:
results = google_search(search_keywords)
if (len(results) == 0):
if len(results) == 0:
return "Found no results."
return "Found Result: [{}]({})".format(results[0]['name'], results[0]['url'])
except Exception as e:
logging.exception(str(e))
return 'Error: Search failed. {}.'.format(e)
class GoogleSearchHandler:
'''
This plugin allows users to enter a search
@ -87,4 +88,5 @@ class GoogleSearchHandler:
result = get_google_result(original_content)
bot_handler.send_reply(message, result)
handler_class = GoogleSearchHandler

View file

@ -11,7 +11,7 @@ class TestGoogleSearchBot(BotTestCase, DefaultTests):
with self.mock_http_conversation('test_normal'):
self.verify_reply(
'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:
@ -31,9 +31,10 @@ class TestGoogleSearchBot(BotTestCase, DefaultTests):
self.verify_reply('no res', 'Found no results.')
def test_attribute_error(self) -> None:
with self.mock_http_conversation('test_attribute_error'), \
patch('logging.exception'):
self.verify_reply('test', 'Error: Search failed. \'NoneType\' object has no attribute \'findAll\'.')
with self.mock_http_conversation('test_attribute_error'), patch('logging.exception'):
self.verify_reply(
'test', 'Error: Search failed. \'NoneType\' object has no attribute \'findAll\'.'
)
# Makes sure cached results, irrelevant links, or empty results are not displayed
def test_ignore_links(self) -> None:
@ -43,5 +44,5 @@ class TestGoogleSearchBot(BotTestCase, DefaultTests):
# See test_ignore_links.json
self.verify_reply(
'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
cloud translate from the google cloud console.
'''
def usage(self):
return '''
This plugin allows users translate messages
@ -27,12 +28,15 @@ class GoogleTranslateHandler:
bot_handler.quit(str(e))
def handle_message(self, message, bot_handler):
bot_response = get_translate_bot_response(message['content'],
bot_response = get_translate_bot_response(
message['content'],
self.config_info,
message['sender_full_name'],
self.supported_languages)
self.supported_languages,
)
bot_handler.send_reply(message, bot_response)
api_url = 'https://translation.googleapis.com/language/translate/v2'
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'
def get_supported_languages(key):
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:
languages = response.json()['data']['languages']
return {lang['name'].lower(): lang['language'].lower() for lang in languages}
raise TranslateError(response.json()['error']['message'])
class TranslateError(Exception):
pass
def translate(text_to_translate, key, dest, src):
parameters = {'q': text_to_translate, 'target': dest, 'key': key}
if src != '':
@ -64,6 +71,7 @@ def translate(text_to_translate, key, dest, src):
return response.json()['data']['translations'][0]['translatedText']
raise TranslateError(response.json()['error']['message'])
def get_code_for_language(language, all_languages):
if language.lower() not in all_languages.values():
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()]
return language
def get_translate_bot_response(message_content, config_file, author, all_languages):
message_content = message_content.strip()
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 == '':
return language_not_found_text.format("Source")
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:
return "Could not connect to Google Translate. {}.".format(conn_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 "{} (from {})".format(translated_text, author)
handler_class = GoogleTranslateHandler

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