black: Reformat skipping string normalization.
This commit is contained in:
parent
5580c68ae5
commit
fba21bb00d
|
@ -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,
|
||||
|
|
96
tools/deploy
96
tools/deploy
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
26
tools/lint
26
tools/lint
|
@ -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()
|
||||
|
|
|
@ -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, '/')
|
||||
|
|
|
@ -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()
|
||||
|
|
11
tools/review
11
tools/review
|
@ -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()
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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"]))
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -10,5 +10,5 @@ config = {
|
|||
"username": "slack username",
|
||||
"token": "slack token",
|
||||
"channel": "C5Z5N7R8A -- must be channel id",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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")]
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]]
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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]]
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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}
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()]))
|
||||
|
|
|
@ -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}
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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),
|
||||
])
|
||||
]
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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, '')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.'
|
||||
|
|
|
@ -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 {}'.format(d['type'], d['definition'], html2text.html2text(example))
|
||||
response += '\n' + '* (**{}**) {}\n {}'.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
|
||||
|
|
|
@ -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 their pet cat\n\n")
|
||||
"developed.\n 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"
|
||||
" 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"
|
||||
" I asked for help from my neighbours\n\n\n"
|
||||
"* (**exclamation**) used as an appeal for urgent assistance.\n"
|
||||
" Help! I'm drowning!\n\n")
|
||||
" 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.')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.",
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:',
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)',
|
||||
)
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue