black: Reformat skipping string normalization.
This commit is contained in:
parent
5580c68ae5
commit
fba21bb00d
|
@ -8,89 +8,97 @@ if MYPY:
|
||||||
|
|
||||||
whitespace_rules = [
|
whitespace_rules = [
|
||||||
# This linter should be first since bash_rules depends on it.
|
# This linter should be first since bash_rules depends on it.
|
||||||
{'pattern': r'\s+$',
|
{'pattern': r'\s+$', 'strip': '\n', 'description': 'Fix trailing whitespace'},
|
||||||
'strip': '\n',
|
{'pattern': '\t', 'strip': '\n', 'description': 'Fix tab-based whitespace'},
|
||||||
'description': 'Fix trailing whitespace'},
|
|
||||||
{'pattern': '\t',
|
|
||||||
'strip': '\n',
|
|
||||||
'description': 'Fix tab-based whitespace'},
|
|
||||||
] # type: List[Rule]
|
] # type: List[Rule]
|
||||||
|
|
||||||
markdown_whitespace_rules = list([rule for rule in whitespace_rules if rule['pattern'] != r'\s+$']) + [
|
markdown_whitespace_rules = list(
|
||||||
|
[rule for rule in whitespace_rules if rule['pattern'] != r'\s+$']
|
||||||
|
) + [
|
||||||
# Two spaces trailing a line with other content is okay--it's a markdown line break.
|
# Two spaces trailing a line with other content is okay--it's a markdown line break.
|
||||||
# This rule finds one space trailing a non-space, three or more trailing spaces, and
|
# This rule finds one space trailing a non-space, three or more trailing spaces, and
|
||||||
# spaces on an empty line.
|
# spaces on an empty line.
|
||||||
{'pattern': r'((?<!\s)\s$)|(\s\s\s+$)|(^\s+$)',
|
{
|
||||||
'strip': '\n',
|
'pattern': r'((?<!\s)\s$)|(\s\s\s+$)|(^\s+$)',
|
||||||
'description': 'Fix trailing whitespace'},
|
'strip': '\n',
|
||||||
{'pattern': r'^#+[A-Za-z0-9]',
|
'description': 'Fix trailing whitespace',
|
||||||
'strip': '\n',
|
},
|
||||||
'description': 'Missing space after # in heading'},
|
{
|
||||||
|
'pattern': r'^#+[A-Za-z0-9]',
|
||||||
|
'strip': '\n',
|
||||||
|
'description': 'Missing space after # in heading',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
python_rules = RuleList(
|
python_rules = RuleList(
|
||||||
langs=['py'],
|
langs=['py'],
|
||||||
rules=[
|
rules=[
|
||||||
{'pattern': r'".*"%\([a-z_].*\)?$',
|
{'pattern': r'".*"%\([a-z_].*\)?$', 'description': 'Missing space around "%"'},
|
||||||
'description': 'Missing space around "%"'},
|
{'pattern': r"'.*'%\([a-z_].*\)?$", 'description': 'Missing space around "%"'},
|
||||||
{'pattern': r"'.*'%\([a-z_].*\)?$",
|
|
||||||
'description': 'Missing space around "%"'},
|
|
||||||
# This rule is constructed with + to avoid triggering on itself
|
# This rule is constructed with + to avoid triggering on itself
|
||||||
{'pattern': r" =" + r'[^ =>~"]',
|
{'pattern': r" =" + r'[^ =>~"]', 'description': 'Missing whitespace after "="'},
|
||||||
'description': 'Missing whitespace after "="'},
|
{'pattern': r'":\w[^"]*$', 'description': 'Missing whitespace after ":"'},
|
||||||
{'pattern': r'":\w[^"]*$',
|
{'pattern': r"':\w[^']*$", 'description': 'Missing whitespace after ":"'},
|
||||||
'description': 'Missing whitespace after ":"'},
|
{'pattern': r"^\s+[#]\w", 'strip': '\n', 'description': 'Missing whitespace after "#"'},
|
||||||
{'pattern': r"':\w[^']*$",
|
{
|
||||||
'description': 'Missing whitespace after ":"'},
|
'pattern': r"assertEquals[(]",
|
||||||
{'pattern': r"^\s+[#]\w",
|
'description': 'Use assertEqual, not assertEquals (which is deprecated).',
|
||||||
'strip': '\n',
|
},
|
||||||
'description': 'Missing whitespace after "#"'},
|
{
|
||||||
{'pattern': r"assertEquals[(]",
|
'pattern': r'self: Any',
|
||||||
'description': 'Use assertEqual, not assertEquals (which is deprecated).'},
|
'description': 'you can omit Any annotation for self',
|
||||||
{'pattern': r'self: Any',
|
'good_lines': ['def foo (self):'],
|
||||||
'description': 'you can omit Any annotation for self',
|
'bad_lines': ['def foo(self: Any):'],
|
||||||
'good_lines': ['def foo (self):'],
|
},
|
||||||
'bad_lines': ['def foo(self: Any):']},
|
{'pattern': r"== None", 'description': 'Use `is None` to check whether something is None'},
|
||||||
{'pattern': r"== None",
|
{'pattern': r"type:[(]", 'description': 'Missing whitespace after ":" in type annotation'},
|
||||||
'description': 'Use `is None` to check whether something is None'},
|
{'pattern': r"# type [(]", 'description': 'Missing : after type in type annotation'},
|
||||||
{'pattern': r"type:[(]",
|
{'pattern': r"#type", 'description': 'Missing whitespace after "#" in type annotation'},
|
||||||
'description': 'Missing whitespace after ":" in type annotation'},
|
{'pattern': r'if[(]', 'description': 'Missing space between if and ('},
|
||||||
{'pattern': r"# type [(]",
|
{'pattern': r", [)]", 'description': 'Unnecessary whitespace between "," and ")"'},
|
||||||
'description': 'Missing : after type in type annotation'},
|
{'pattern': r"% [(]", 'description': 'Unnecessary whitespace between "%" and "("'},
|
||||||
{'pattern': r"#type",
|
|
||||||
'description': 'Missing whitespace after "#" in type annotation'},
|
|
||||||
{'pattern': r'if[(]',
|
|
||||||
'description': 'Missing space between if and ('},
|
|
||||||
{'pattern': r", [)]",
|
|
||||||
'description': 'Unnecessary whitespace between "," and ")"'},
|
|
||||||
{'pattern': r"% [(]",
|
|
||||||
'description': 'Unnecessary whitespace between "%" and "("'},
|
|
||||||
# This next check could have false positives, but it seems pretty
|
# This next check could have false positives, but it seems pretty
|
||||||
# rare; if we find any, they can be added to the exclude list for
|
# rare; if we find any, they can be added to the exclude list for
|
||||||
# this rule.
|
# this rule.
|
||||||
{'pattern': r' % [a-zA-Z0-9_.]*\)?$',
|
{
|
||||||
'description': 'Used % comprehension without a tuple'},
|
'pattern': r' % [a-zA-Z0-9_.]*\)?$',
|
||||||
{'pattern': r'.*%s.* % \([a-zA-Z0-9_.]*\)$',
|
'description': 'Used % comprehension without a tuple',
|
||||||
'description': 'Used % comprehension without a tuple'},
|
},
|
||||||
{'pattern': r'__future__',
|
{
|
||||||
'include_only': {'zulip_bots/zulip_bots/bots/'},
|
'pattern': r'.*%s.* % \([a-zA-Z0-9_.]*\)$',
|
||||||
'description': 'Bots no longer need __future__ imports.'},
|
'description': 'Used % comprehension without a tuple',
|
||||||
{'pattern': r'#!/usr/bin/env python$',
|
},
|
||||||
'include_only': {'zulip_bots/'},
|
{
|
||||||
'description': 'Python shebangs must be python3'},
|
'pattern': r'__future__',
|
||||||
{'pattern': r'(^|\s)open\s*\(',
|
'include_only': {'zulip_bots/zulip_bots/bots/'},
|
||||||
'description': 'open() should not be used in Zulip\'s bots. Use functions'
|
'description': 'Bots no longer need __future__ imports.',
|
||||||
' provided by the bots framework to access the filesystem.',
|
},
|
||||||
'include_only': {'zulip_bots/zulip_bots/bots/'}},
|
{
|
||||||
{'pattern': r'pprint',
|
'pattern': r'#!/usr/bin/env python$',
|
||||||
'description': 'Used pprint, which is most likely a debugging leftover. For user output, use print().'},
|
'include_only': {'zulip_bots/'},
|
||||||
{'pattern': r'\(BotTestCase\)',
|
'description': 'Python shebangs must be python3',
|
||||||
'bad_lines': ['class TestSomeBot(BotTestCase):'],
|
},
|
||||||
'description': 'Bot test cases should directly inherit from BotTestCase *and* DefaultTests.'},
|
{
|
||||||
{'pattern': r'\(DefaultTests, BotTestCase\)',
|
'pattern': r'(^|\s)open\s*\(',
|
||||||
'bad_lines': ['class TestSomeBot(DefaultTests, BotTestCase):'],
|
'description': 'open() should not be used in Zulip\'s bots. Use functions'
|
||||||
'good_lines': ['class TestSomeBot(BotTestCase, DefaultTests):'],
|
' provided by the bots framework to access the filesystem.',
|
||||||
'description': 'Bot test cases should inherit from BotTestCase before DefaultTests.'},
|
'include_only': {'zulip_bots/zulip_bots/bots/'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'pattern': r'pprint',
|
||||||
|
'description': 'Used pprint, which is most likely a debugging leftover. For user output, use print().',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'pattern': r'\(BotTestCase\)',
|
||||||
|
'bad_lines': ['class TestSomeBot(BotTestCase):'],
|
||||||
|
'description': 'Bot test cases should directly inherit from BotTestCase *and* DefaultTests.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'pattern': r'\(DefaultTests, BotTestCase\)',
|
||||||
|
'bad_lines': ['class TestSomeBot(DefaultTests, BotTestCase):'],
|
||||||
|
'good_lines': ['class TestSomeBot(BotTestCase, DefaultTests):'],
|
||||||
|
'description': 'Bot test cases should inherit from BotTestCase before DefaultTests.',
|
||||||
|
},
|
||||||
*whitespace_rules,
|
*whitespace_rules,
|
||||||
],
|
],
|
||||||
max_length=140,
|
max_length=140,
|
||||||
|
@ -99,9 +107,11 @@ python_rules = RuleList(
|
||||||
bash_rules = RuleList(
|
bash_rules = RuleList(
|
||||||
langs=['sh'],
|
langs=['sh'],
|
||||||
rules=[
|
rules=[
|
||||||
{'pattern': r'#!.*sh [-xe]',
|
{
|
||||||
'description': 'Fix shebang line with proper call to /usr/bin/env for Bash path, change -x|-e switches'
|
'pattern': r'#!.*sh [-xe]',
|
||||||
' to set -x|set -e'},
|
'description': 'Fix shebang line with proper call to /usr/bin/env for Bash path, change -x|-e switches'
|
||||||
|
' to set -x|set -e',
|
||||||
|
},
|
||||||
*whitespace_rules[0:1],
|
*whitespace_rules[0:1],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -116,20 +126,27 @@ json_rules = RuleList(
|
||||||
# version of the tab-based whitespace rule (we can't just use
|
# version of the tab-based whitespace rule (we can't just use
|
||||||
# exclude in whitespace_rules, since we only want to ignore
|
# exclude in whitespace_rules, since we only want to ignore
|
||||||
# JSON files with tab-based whitespace, not webhook code).
|
# JSON files with tab-based whitespace, not webhook code).
|
||||||
rules= whitespace_rules[0:1],
|
rules=whitespace_rules[0:1],
|
||||||
)
|
)
|
||||||
|
|
||||||
prose_style_rules = [
|
prose_style_rules = [
|
||||||
{'pattern': r'[^\/\#\-"]([jJ]avascript)', # exclude usage in hrefs/divs
|
{
|
||||||
'description': "javascript should be spelled JavaScript"},
|
'pattern': r'[^\/\#\-"]([jJ]avascript)', # exclude usage in hrefs/divs
|
||||||
{'pattern': r'''[^\/\-\."'\_\=\>]([gG]ithub)[^\.\-\_"\<]''', # exclude usage in hrefs/divs
|
'description': "javascript should be spelled JavaScript",
|
||||||
'description': "github should be spelled GitHub"},
|
},
|
||||||
{'pattern': r'[oO]rganisation', # exclude usage in hrefs/divs
|
{
|
||||||
'description': "Organization is spelled with a z"},
|
'pattern': r'''[^\/\-\."'\_\=\>]([gG]ithub)[^\.\-\_"\<]''', # exclude usage in hrefs/divs
|
||||||
{'pattern': r'!!! warning',
|
'description': "github should be spelled GitHub",
|
||||||
'description': "!!! warning is invalid; it's spelled '!!! warn'"},
|
},
|
||||||
{'pattern': r'[^-_]botserver(?!rc)|bot server',
|
{
|
||||||
'description': "Use Botserver instead of botserver or Botserver."},
|
'pattern': r'[oO]rganisation', # exclude usage in hrefs/divs
|
||||||
|
'description': "Organization is spelled with a z",
|
||||||
|
},
|
||||||
|
{'pattern': r'!!! warning', 'description': "!!! warning is invalid; it's spelled '!!! warn'"},
|
||||||
|
{
|
||||||
|
'pattern': r'[^-_]botserver(?!rc)|bot server',
|
||||||
|
'description': "Use Botserver instead of botserver or Botserver.",
|
||||||
|
},
|
||||||
] # type: List[Rule]
|
] # type: List[Rule]
|
||||||
|
|
||||||
markdown_docs_length_exclude = {
|
markdown_docs_length_exclude = {
|
||||||
|
@ -141,8 +158,10 @@ markdown_rules = RuleList(
|
||||||
rules=[
|
rules=[
|
||||||
*markdown_whitespace_rules,
|
*markdown_whitespace_rules,
|
||||||
*prose_style_rules,
|
*prose_style_rules,
|
||||||
{'pattern': r'\[(?P<url>[^\]]+)\]\((?P=url)\)',
|
{
|
||||||
'description': 'Linkified markdown URLs should use cleaner <http://example.com> syntax.'}
|
'pattern': r'\[(?P<url>[^\]]+)\]\((?P=url)\)',
|
||||||
|
'description': 'Linkified markdown URLs should use cleaner <http://example.com> syntax.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
max_length=120,
|
max_length=120,
|
||||||
length_exclude=markdown_docs_length_exclude,
|
length_exclude=markdown_docs_length_exclude,
|
||||||
|
|
100
tools/deploy
100
tools/deploy
|
@ -18,6 +18,7 @@ bold = '\033[1m' # type: str
|
||||||
|
|
||||||
bots_dir = '.bots' # type: str
|
bots_dir = '.bots' # type: str
|
||||||
|
|
||||||
|
|
||||||
def pack(options: argparse.Namespace) -> None:
|
def pack(options: argparse.Namespace) -> None:
|
||||||
# Basic sanity checks for input.
|
# Basic sanity checks for input.
|
||||||
if not options.path:
|
if not options.path:
|
||||||
|
@ -53,15 +54,20 @@ def pack(options: argparse.Namespace) -> None:
|
||||||
# Pack the zuliprc
|
# Pack the zuliprc
|
||||||
zip_file.write(options.config, 'zuliprc')
|
zip_file.write(options.config, 'zuliprc')
|
||||||
# Pack the config file for the botfarm.
|
# Pack the config file for the botfarm.
|
||||||
bot_config = textwrap.dedent('''\
|
bot_config = textwrap.dedent(
|
||||||
|
'''\
|
||||||
[deploy]
|
[deploy]
|
||||||
bot={}
|
bot={}
|
||||||
zuliprc=zuliprc
|
zuliprc=zuliprc
|
||||||
'''.format(options.main))
|
'''.format(
|
||||||
|
options.main
|
||||||
|
)
|
||||||
|
)
|
||||||
zip_file.writestr('config.ini', bot_config)
|
zip_file.writestr('config.ini', bot_config)
|
||||||
zip_file.close()
|
zip_file.close()
|
||||||
print('pack: Created zip file at: {}.'.format(zip_file_path))
|
print('pack: Created zip file at: {}.'.format(zip_file_path))
|
||||||
|
|
||||||
|
|
||||||
def check_common_options(options: argparse.Namespace) -> None:
|
def check_common_options(options: argparse.Namespace) -> None:
|
||||||
if not options.server:
|
if not options.server:
|
||||||
print('tools/deploy: URL to Botfarm server not specified.')
|
print('tools/deploy: URL to Botfarm server not specified.')
|
||||||
|
@ -70,18 +76,20 @@ def check_common_options(options: argparse.Namespace) -> None:
|
||||||
print('tools/deploy: Botfarm deploy token not specified.')
|
print('tools/deploy: Botfarm deploy token not specified.')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def handle_common_response_without_data(response: Response,
|
|
||||||
operation: str,
|
def handle_common_response_without_data(
|
||||||
success_message: str) -> bool:
|
response: Response, operation: str, success_message: str
|
||||||
|
) -> bool:
|
||||||
return handle_common_response(
|
return handle_common_response(
|
||||||
response=response,
|
response=response,
|
||||||
operation=operation,
|
operation=operation,
|
||||||
success_handler=lambda r: print('{}: {}'.format(operation, success_message))
|
success_handler=lambda r: print('{}: {}'.format(operation, success_message)),
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle_common_response(response: Response,
|
|
||||||
operation: str,
|
def handle_common_response(
|
||||||
success_handler: Callable[[Dict[str, Any]], Any]) -> bool:
|
response: Response, operation: str, success_handler: Callable[[Dict[str, Any]], Any]
|
||||||
|
) -> bool:
|
||||||
if response.status_code == requests.codes.ok:
|
if response.status_code == requests.codes.ok:
|
||||||
response_data = response.json()
|
response_data = response.json()
|
||||||
if response_data['status'] == 'success':
|
if response_data['status'] == 'success':
|
||||||
|
@ -99,6 +107,7 @@ def handle_common_response(response: Response,
|
||||||
print('{}: Error {}. Aborting.'.format(operation, response.status_code))
|
print('{}: Error {}. Aborting.'.format(operation, response.status_code))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def upload(options: argparse.Namespace) -> None:
|
def upload(options: argparse.Namespace) -> None:
|
||||||
check_common_options(options)
|
check_common_options(options)
|
||||||
file_path = os.path.join(bots_dir, options.botname + '.zip')
|
file_path = os.path.join(bots_dir, options.botname + '.zip')
|
||||||
|
@ -109,10 +118,13 @@ def upload(options: argparse.Namespace) -> None:
|
||||||
headers = {'key': options.token}
|
headers = {'key': options.token}
|
||||||
url = urllib.parse.urljoin(options.server, 'bots/upload')
|
url = urllib.parse.urljoin(options.server, 'bots/upload')
|
||||||
response = requests.post(url, files=files, headers=headers)
|
response = requests.post(url, files=files, headers=headers)
|
||||||
result = handle_common_response_without_data(response, 'upload', 'Uploaded the bot package to botfarm.')
|
result = handle_common_response_without_data(
|
||||||
|
response, 'upload', 'Uploaded the bot package to botfarm.'
|
||||||
|
)
|
||||||
if result is False:
|
if result is False:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def clean(options: argparse.Namespace) -> None:
|
def clean(options: argparse.Namespace) -> None:
|
||||||
file_path = os.path.join(bots_dir, options.botname + '.zip')
|
file_path = os.path.join(bots_dir, options.botname + '.zip')
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
|
@ -121,42 +133,53 @@ def clean(options: argparse.Namespace) -> None:
|
||||||
else:
|
else:
|
||||||
print('clean: File \'{}\' not found.'.format(file_path))
|
print('clean: File \'{}\' not found.'.format(file_path))
|
||||||
|
|
||||||
|
|
||||||
def process(options: argparse.Namespace) -> None:
|
def process(options: argparse.Namespace) -> None:
|
||||||
check_common_options(options)
|
check_common_options(options)
|
||||||
headers = {'key': options.token}
|
headers = {'key': options.token}
|
||||||
url = urllib.parse.urljoin(options.server, 'bots/process')
|
url = urllib.parse.urljoin(options.server, 'bots/process')
|
||||||
payload = {'name': options.botname}
|
payload = {'name': options.botname}
|
||||||
response = requests.post(url, headers=headers, json=payload)
|
response = requests.post(url, headers=headers, json=payload)
|
||||||
result = handle_common_response_without_data(response, 'process', 'The bot has been processed by the botfarm.')
|
result = handle_common_response_without_data(
|
||||||
|
response, 'process', 'The bot has been processed by the botfarm.'
|
||||||
|
)
|
||||||
if result is False:
|
if result is False:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def start(options: argparse.Namespace) -> None:
|
def start(options: argparse.Namespace) -> None:
|
||||||
check_common_options(options)
|
check_common_options(options)
|
||||||
headers = {'key': options.token}
|
headers = {'key': options.token}
|
||||||
url = urllib.parse.urljoin(options.server, 'bots/start')
|
url = urllib.parse.urljoin(options.server, 'bots/start')
|
||||||
payload = {'name': options.botname}
|
payload = {'name': options.botname}
|
||||||
response = requests.post(url, headers=headers, json=payload)
|
response = requests.post(url, headers=headers, json=payload)
|
||||||
result = handle_common_response_without_data(response, 'start', 'The bot has been started by the botfarm.')
|
result = handle_common_response_without_data(
|
||||||
|
response, 'start', 'The bot has been started by the botfarm.'
|
||||||
|
)
|
||||||
if result is False:
|
if result is False:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def stop(options: argparse.Namespace) -> None:
|
def stop(options: argparse.Namespace) -> None:
|
||||||
check_common_options(options)
|
check_common_options(options)
|
||||||
headers = {'key': options.token}
|
headers = {'key': options.token}
|
||||||
url = urllib.parse.urljoin(options.server, 'bots/stop')
|
url = urllib.parse.urljoin(options.server, 'bots/stop')
|
||||||
payload = {'name': options.botname}
|
payload = {'name': options.botname}
|
||||||
response = requests.post(url, headers=headers, json=payload)
|
response = requests.post(url, headers=headers, json=payload)
|
||||||
result = handle_common_response_without_data(response, 'stop', 'The bot has been stopped by the botfarm.')
|
result = handle_common_response_without_data(
|
||||||
|
response, 'stop', 'The bot has been stopped by the botfarm.'
|
||||||
|
)
|
||||||
if result is False:
|
if result is False:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def prepare(options: argparse.Namespace) -> None:
|
def prepare(options: argparse.Namespace) -> None:
|
||||||
pack(options)
|
pack(options)
|
||||||
upload(options)
|
upload(options)
|
||||||
clean(options)
|
clean(options)
|
||||||
process(options)
|
process(options)
|
||||||
|
|
||||||
|
|
||||||
def log(options: argparse.Namespace) -> None:
|
def log(options: argparse.Namespace) -> None:
|
||||||
check_common_options(options)
|
check_common_options(options)
|
||||||
headers = {'key': options.token}
|
headers = {'key': options.token}
|
||||||
|
@ -171,16 +194,20 @@ def log(options: argparse.Namespace) -> None:
|
||||||
if result is False:
|
if result is False:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def delete(options: argparse.Namespace) -> None:
|
def delete(options: argparse.Namespace) -> None:
|
||||||
check_common_options(options)
|
check_common_options(options)
|
||||||
headers = {'key': options.token}
|
headers = {'key': options.token}
|
||||||
url = urllib.parse.urljoin(options.server, 'bots/delete')
|
url = urllib.parse.urljoin(options.server, 'bots/delete')
|
||||||
payload = {'name': options.botname}
|
payload = {'name': options.botname}
|
||||||
response = requests.post(url, headers=headers, json=payload)
|
response = requests.post(url, headers=headers, json=payload)
|
||||||
result = handle_common_response_without_data(response, 'delete', 'The bot has been removed from the botfarm.')
|
result = handle_common_response_without_data(
|
||||||
|
response, 'delete', 'The bot has been removed from the botfarm.'
|
||||||
|
)
|
||||||
if result is False:
|
if result is False:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def list_bots(options: argparse.Namespace) -> None:
|
def list_bots(options: argparse.Namespace) -> None:
|
||||||
check_common_options(options)
|
check_common_options(options)
|
||||||
headers = {'key': options.token}
|
headers = {'key': options.token}
|
||||||
|
@ -190,10 +217,13 @@ def list_bots(options: argparse.Namespace) -> None:
|
||||||
pretty_print = False
|
pretty_print = False
|
||||||
url = urllib.parse.urljoin(options.server, 'bots/list')
|
url = urllib.parse.urljoin(options.server, 'bots/list')
|
||||||
response = requests.get(url, headers=headers)
|
response = requests.get(url, headers=headers)
|
||||||
result = handle_common_response(response, 'ls', lambda r: print_bots(r['bots']['list'], pretty_print))
|
result = handle_common_response(
|
||||||
|
response, 'ls', lambda r: print_bots(r['bots']['list'], pretty_print)
|
||||||
|
)
|
||||||
if result is False:
|
if result is False:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def print_bots(bots: List[Any], pretty_print: bool) -> None:
|
def print_bots(bots: List[Any], pretty_print: bool) -> None:
|
||||||
if pretty_print:
|
if pretty_print:
|
||||||
print_bots_pretty(bots)
|
print_bots_pretty(bots)
|
||||||
|
@ -201,6 +231,7 @@ def print_bots(bots: List[Any], pretty_print: bool) -> None:
|
||||||
for bot in bots:
|
for bot in bots:
|
||||||
print('{}\t{}\t{}\t{}'.format(bot['name'], bot['status'], bot['email'], bot['site']))
|
print('{}\t{}\t{}\t{}'.format(bot['name'], bot['status'], bot['email'], bot['site']))
|
||||||
|
|
||||||
|
|
||||||
def print_bots_pretty(bots: List[Any]) -> None:
|
def print_bots_pretty(bots: List[Any]) -> None:
|
||||||
if len(bots) == 0:
|
if len(bots) == 0:
|
||||||
print('ls: No bots found on the botfarm')
|
print('ls: No bots found on the botfarm')
|
||||||
|
@ -231,6 +262,7 @@ def print_bots_pretty(bots: List[Any]) -> None:
|
||||||
)
|
)
|
||||||
print(row)
|
print(row)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
usage = """tools/deploy <command> <bot-name> [options]
|
usage = """tools/deploy <command> <bot-name> [options]
|
||||||
|
|
||||||
|
@ -267,23 +299,25 @@ To list user's bots, use:
|
||||||
parser = argparse.ArgumentParser(usage=usage)
|
parser = argparse.ArgumentParser(usage=usage)
|
||||||
parser.add_argument('command', help='Command to run.')
|
parser.add_argument('command', help='Command to run.')
|
||||||
parser.add_argument('botname', nargs='?', help='Name of bot to operate on.')
|
parser.add_argument('botname', nargs='?', help='Name of bot to operate on.')
|
||||||
parser.add_argument('--server', '-s',
|
parser.add_argument(
|
||||||
metavar='SERVERURL',
|
'--server',
|
||||||
default=os.environ.get('SERVER', ''),
|
'-s',
|
||||||
help='Url of the Zulip Botfarm server.')
|
metavar='SERVERURL',
|
||||||
parser.add_argument('--token', '-t',
|
default=os.environ.get('SERVER', ''),
|
||||||
default=os.environ.get('TOKEN', ''),
|
help='Url of the Zulip Botfarm server.',
|
||||||
help='Deploy Token for the Botfarm.')
|
)
|
||||||
parser.add_argument('--path', '-p',
|
parser.add_argument(
|
||||||
help='Path to the bot directory.')
|
'--token', '-t', default=os.environ.get('TOKEN', ''), help='Deploy Token for the Botfarm.'
|
||||||
parser.add_argument('--config', '-c',
|
)
|
||||||
help='Path to the zuliprc file.')
|
parser.add_argument('--path', '-p', help='Path to the bot directory.')
|
||||||
parser.add_argument('--main', '-m',
|
parser.add_argument('--config', '-c', help='Path to the zuliprc file.')
|
||||||
help='Path to the bot\'s main file, relative to the bot\'s directory.')
|
parser.add_argument(
|
||||||
parser.add_argument('--lines', '-l',
|
'--main', '-m', help='Path to the bot\'s main file, relative to the bot\'s directory.'
|
||||||
help='Number of lines in log required.')
|
)
|
||||||
parser.add_argument('--format', '-f', action='store_true',
|
parser.add_argument('--lines', '-l', help='Number of lines in log required.')
|
||||||
help='Print user\'s bots in human readable format')
|
parser.add_argument(
|
||||||
|
'--format', '-f', action='store_true', help='Print user\'s bots in human readable format'
|
||||||
|
)
|
||||||
options = parser.parse_args()
|
options = parser.parse_args()
|
||||||
if not options.command:
|
if not options.command:
|
||||||
print('tools/deploy: No command specified.')
|
print('tools/deploy: No command specified.')
|
||||||
|
@ -308,5 +342,7 @@ To list user's bots, use:
|
||||||
commands[options.command](options)
|
commands[options.command](options)
|
||||||
else:
|
else:
|
||||||
print('tools/deploy: No command \'{}\' found.'.format(options.command))
|
print('tools/deploy: No command \'{}\' found.'.format(options.command))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
|
@ -11,84 +11,253 @@ from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
|
||||||
# License: MIT
|
# License: MIT
|
||||||
# Ref: fit_commit/validators/tense.rb
|
# Ref: fit_commit/validators/tense.rb
|
||||||
WORD_SET = {
|
WORD_SET = {
|
||||||
'adds', 'adding', 'added',
|
'adds',
|
||||||
'allows', 'allowing', 'allowed',
|
'adding',
|
||||||
'amends', 'amending', 'amended',
|
'added',
|
||||||
'bumps', 'bumping', 'bumped',
|
'allows',
|
||||||
'calculates', 'calculating', 'calculated',
|
'allowing',
|
||||||
'changes', 'changing', 'changed',
|
'allowed',
|
||||||
'cleans', 'cleaning', 'cleaned',
|
'amends',
|
||||||
'commits', 'committing', 'committed',
|
'amending',
|
||||||
'corrects', 'correcting', 'corrected',
|
'amended',
|
||||||
'creates', 'creating', 'created',
|
'bumps',
|
||||||
'darkens', 'darkening', 'darkened',
|
'bumping',
|
||||||
'disables', 'disabling', 'disabled',
|
'bumped',
|
||||||
'displays', 'displaying', 'displayed',
|
'calculates',
|
||||||
'documents', 'documenting', 'documented',
|
'calculating',
|
||||||
'drys', 'drying', 'dryed',
|
'calculated',
|
||||||
'ends', 'ending', 'ended',
|
'changes',
|
||||||
'enforces', 'enforcing', 'enforced',
|
'changing',
|
||||||
'enqueues', 'enqueuing', 'enqueued',
|
'changed',
|
||||||
'extracts', 'extracting', 'extracted',
|
'cleans',
|
||||||
'finishes', 'finishing', 'finished',
|
'cleaning',
|
||||||
'fixes', 'fixing', 'fixed',
|
'cleaned',
|
||||||
'formats', 'formatting', 'formatted',
|
'commits',
|
||||||
'guards', 'guarding', 'guarded',
|
'committing',
|
||||||
'handles', 'handling', 'handled',
|
'committed',
|
||||||
'hides', 'hiding', 'hid',
|
'corrects',
|
||||||
'increases', 'increasing', 'increased',
|
'correcting',
|
||||||
'ignores', 'ignoring', 'ignored',
|
'corrected',
|
||||||
'implements', 'implementing', 'implemented',
|
'creates',
|
||||||
'improves', 'improving', 'improved',
|
'creating',
|
||||||
'keeps', 'keeping', 'kept',
|
'created',
|
||||||
'kills', 'killing', 'killed',
|
'darkens',
|
||||||
'makes', 'making', 'made',
|
'darkening',
|
||||||
'merges', 'merging', 'merged',
|
'darkened',
|
||||||
'moves', 'moving', 'moved',
|
'disables',
|
||||||
'permits', 'permitting', 'permitted',
|
'disabling',
|
||||||
'prevents', 'preventing', 'prevented',
|
'disabled',
|
||||||
'pushes', 'pushing', 'pushed',
|
'displays',
|
||||||
'rebases', 'rebasing', 'rebased',
|
'displaying',
|
||||||
'refactors', 'refactoring', 'refactored',
|
'displayed',
|
||||||
'removes', 'removing', 'removed',
|
'documents',
|
||||||
'renames', 'renaming', 'renamed',
|
'documenting',
|
||||||
'reorders', 'reordering', 'reordered',
|
'documented',
|
||||||
'replaces', 'replacing', 'replaced',
|
'drys',
|
||||||
'requires', 'requiring', 'required',
|
'drying',
|
||||||
'restores', 'restoring', 'restored',
|
'dryed',
|
||||||
'sends', 'sending', 'sent',
|
'ends',
|
||||||
'sets', 'setting',
|
'ending',
|
||||||
'separates', 'separating', 'separated',
|
'ended',
|
||||||
'shows', 'showing', 'showed',
|
'enforces',
|
||||||
'simplifies', 'simplifying', 'simplified',
|
'enforcing',
|
||||||
'skips', 'skipping', 'skipped',
|
'enforced',
|
||||||
'sorts', 'sorting',
|
'enqueues',
|
||||||
'speeds', 'speeding', 'sped',
|
'enqueuing',
|
||||||
'starts', 'starting', 'started',
|
'enqueued',
|
||||||
'supports', 'supporting', 'supported',
|
'extracts',
|
||||||
'takes', 'taking', 'took',
|
'extracting',
|
||||||
'testing', 'tested', # 'tests' excluded to reduce false negative
|
'extracted',
|
||||||
'truncates', 'truncating', 'truncated',
|
'finishes',
|
||||||
'updates', 'updating', 'updated',
|
'finishing',
|
||||||
'uses', 'using', 'used',
|
'finished',
|
||||||
|
'fixes',
|
||||||
|
'fixing',
|
||||||
|
'fixed',
|
||||||
|
'formats',
|
||||||
|
'formatting',
|
||||||
|
'formatted',
|
||||||
|
'guards',
|
||||||
|
'guarding',
|
||||||
|
'guarded',
|
||||||
|
'handles',
|
||||||
|
'handling',
|
||||||
|
'handled',
|
||||||
|
'hides',
|
||||||
|
'hiding',
|
||||||
|
'hid',
|
||||||
|
'increases',
|
||||||
|
'increasing',
|
||||||
|
'increased',
|
||||||
|
'ignores',
|
||||||
|
'ignoring',
|
||||||
|
'ignored',
|
||||||
|
'implements',
|
||||||
|
'implementing',
|
||||||
|
'implemented',
|
||||||
|
'improves',
|
||||||
|
'improving',
|
||||||
|
'improved',
|
||||||
|
'keeps',
|
||||||
|
'keeping',
|
||||||
|
'kept',
|
||||||
|
'kills',
|
||||||
|
'killing',
|
||||||
|
'killed',
|
||||||
|
'makes',
|
||||||
|
'making',
|
||||||
|
'made',
|
||||||
|
'merges',
|
||||||
|
'merging',
|
||||||
|
'merged',
|
||||||
|
'moves',
|
||||||
|
'moving',
|
||||||
|
'moved',
|
||||||
|
'permits',
|
||||||
|
'permitting',
|
||||||
|
'permitted',
|
||||||
|
'prevents',
|
||||||
|
'preventing',
|
||||||
|
'prevented',
|
||||||
|
'pushes',
|
||||||
|
'pushing',
|
||||||
|
'pushed',
|
||||||
|
'rebases',
|
||||||
|
'rebasing',
|
||||||
|
'rebased',
|
||||||
|
'refactors',
|
||||||
|
'refactoring',
|
||||||
|
'refactored',
|
||||||
|
'removes',
|
||||||
|
'removing',
|
||||||
|
'removed',
|
||||||
|
'renames',
|
||||||
|
'renaming',
|
||||||
|
'renamed',
|
||||||
|
'reorders',
|
||||||
|
'reordering',
|
||||||
|
'reordered',
|
||||||
|
'replaces',
|
||||||
|
'replacing',
|
||||||
|
'replaced',
|
||||||
|
'requires',
|
||||||
|
'requiring',
|
||||||
|
'required',
|
||||||
|
'restores',
|
||||||
|
'restoring',
|
||||||
|
'restored',
|
||||||
|
'sends',
|
||||||
|
'sending',
|
||||||
|
'sent',
|
||||||
|
'sets',
|
||||||
|
'setting',
|
||||||
|
'separates',
|
||||||
|
'separating',
|
||||||
|
'separated',
|
||||||
|
'shows',
|
||||||
|
'showing',
|
||||||
|
'showed',
|
||||||
|
'simplifies',
|
||||||
|
'simplifying',
|
||||||
|
'simplified',
|
||||||
|
'skips',
|
||||||
|
'skipping',
|
||||||
|
'skipped',
|
||||||
|
'sorts',
|
||||||
|
'sorting',
|
||||||
|
'speeds',
|
||||||
|
'speeding',
|
||||||
|
'sped',
|
||||||
|
'starts',
|
||||||
|
'starting',
|
||||||
|
'started',
|
||||||
|
'supports',
|
||||||
|
'supporting',
|
||||||
|
'supported',
|
||||||
|
'takes',
|
||||||
|
'taking',
|
||||||
|
'took',
|
||||||
|
'testing',
|
||||||
|
'tested', # 'tests' excluded to reduce false negative
|
||||||
|
'truncates',
|
||||||
|
'truncating',
|
||||||
|
'truncated',
|
||||||
|
'updates',
|
||||||
|
'updating',
|
||||||
|
'updated',
|
||||||
|
'uses',
|
||||||
|
'using',
|
||||||
|
'used',
|
||||||
}
|
}
|
||||||
|
|
||||||
imperative_forms = [
|
imperative_forms = [
|
||||||
'add', 'allow', 'amend', 'bump', 'calculate', 'change', 'clean', 'commit',
|
'add',
|
||||||
'correct', 'create', 'darken', 'disable', 'display', 'document', 'dry',
|
'allow',
|
||||||
'end', 'enforce', 'enqueue', 'extract', 'finish', 'fix', 'format', 'guard',
|
'amend',
|
||||||
'handle', 'hide', 'ignore', 'implement', 'improve', 'increase', 'keep',
|
'bump',
|
||||||
'kill', 'make', 'merge', 'move', 'permit', 'prevent', 'push', 'rebase',
|
'calculate',
|
||||||
'refactor', 'remove', 'rename', 'reorder', 'replace', 'require', 'restore',
|
'change',
|
||||||
'send', 'separate', 'set', 'show', 'simplify', 'skip', 'sort', 'speed',
|
'clean',
|
||||||
'start', 'support', 'take', 'test', 'truncate', 'update', 'use',
|
'commit',
|
||||||
|
'correct',
|
||||||
|
'create',
|
||||||
|
'darken',
|
||||||
|
'disable',
|
||||||
|
'display',
|
||||||
|
'document',
|
||||||
|
'dry',
|
||||||
|
'end',
|
||||||
|
'enforce',
|
||||||
|
'enqueue',
|
||||||
|
'extract',
|
||||||
|
'finish',
|
||||||
|
'fix',
|
||||||
|
'format',
|
||||||
|
'guard',
|
||||||
|
'handle',
|
||||||
|
'hide',
|
||||||
|
'ignore',
|
||||||
|
'implement',
|
||||||
|
'improve',
|
||||||
|
'increase',
|
||||||
|
'keep',
|
||||||
|
'kill',
|
||||||
|
'make',
|
||||||
|
'merge',
|
||||||
|
'move',
|
||||||
|
'permit',
|
||||||
|
'prevent',
|
||||||
|
'push',
|
||||||
|
'rebase',
|
||||||
|
'refactor',
|
||||||
|
'remove',
|
||||||
|
'rename',
|
||||||
|
'reorder',
|
||||||
|
'replace',
|
||||||
|
'require',
|
||||||
|
'restore',
|
||||||
|
'send',
|
||||||
|
'separate',
|
||||||
|
'set',
|
||||||
|
'show',
|
||||||
|
'simplify',
|
||||||
|
'skip',
|
||||||
|
'sort',
|
||||||
|
'speed',
|
||||||
|
'start',
|
||||||
|
'support',
|
||||||
|
'take',
|
||||||
|
'test',
|
||||||
|
'truncate',
|
||||||
|
'update',
|
||||||
|
'use',
|
||||||
]
|
]
|
||||||
imperative_forms.sort()
|
imperative_forms.sort()
|
||||||
|
|
||||||
|
|
||||||
def head_binary_search(key: str, words: List[str]) -> str:
|
def head_binary_search(key: str, words: List[str]) -> str:
|
||||||
""" Find the imperative mood version of `word` by looking at the first
|
"""Find the imperative mood version of `word` by looking at the first
|
||||||
3 characters. """
|
3 characters."""
|
||||||
|
|
||||||
# Edge case: 'disable' and 'display' have the same 3 starting letters.
|
# Edge case: 'disable' and 'display' have the same 3 starting letters.
|
||||||
if key in ['displays', 'displaying', 'displayed']:
|
if key in ['displays', 'displaying', 'displayed']:
|
||||||
|
@ -114,16 +283,18 @@ def head_binary_search(key: str, words: List[str]) -> str:
|
||||||
|
|
||||||
|
|
||||||
class ImperativeMood(LineRule):
|
class ImperativeMood(LineRule):
|
||||||
""" This rule will enforce that the commit message title uses imperative
|
"""This rule will enforce that the commit message title uses imperative
|
||||||
mood. This is done by checking if the first word is in `WORD_SET`, if so
|
mood. This is done by checking if the first word is in `WORD_SET`, if so
|
||||||
show the word in the correct mood. """
|
show the word in the correct mood."""
|
||||||
|
|
||||||
name = "title-imperative-mood"
|
name = "title-imperative-mood"
|
||||||
id = "Z1"
|
id = "Z1"
|
||||||
target = CommitMessageTitle
|
target = CommitMessageTitle
|
||||||
|
|
||||||
error_msg = ('The first word in commit title should be in imperative mood '
|
error_msg = (
|
||||||
'("{word}" -> "{imperative}"): "{title}"')
|
'The first word in commit title should be in imperative mood '
|
||||||
|
'("{word}" -> "{imperative}"): "{title}"'
|
||||||
|
)
|
||||||
|
|
||||||
def validate(self, line: str, commit: GitCommit) -> List[RuleViolation]:
|
def validate(self, line: str, commit: GitCommit) -> List[RuleViolation]:
|
||||||
violations = []
|
violations = []
|
||||||
|
@ -134,11 +305,14 @@ class ImperativeMood(LineRule):
|
||||||
|
|
||||||
if first_word in WORD_SET:
|
if first_word in WORD_SET:
|
||||||
imperative = head_binary_search(first_word, imperative_forms)
|
imperative = head_binary_search(first_word, imperative_forms)
|
||||||
violation = RuleViolation(self.id, self.error_msg.format(
|
violation = RuleViolation(
|
||||||
word=first_word,
|
self.id,
|
||||||
imperative=imperative,
|
self.error_msg.format(
|
||||||
title=commit.message.title,
|
word=first_word,
|
||||||
))
|
imperative=imperative,
|
||||||
|
title=commit.message.title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
violations.append(violation)
|
violations.append(violation)
|
||||||
|
|
||||||
|
|
26
tools/lint
26
tools/lint
|
@ -12,6 +12,7 @@ EXCLUDED_FILES = [
|
||||||
'zulip/integrations/perforce/git_p4.py',
|
'zulip/integrations/perforce/git_p4.py',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
add_default_linter_arguments(parser)
|
add_default_linter_arguments(parser)
|
||||||
|
@ -19,15 +20,23 @@ def run() -> None:
|
||||||
|
|
||||||
linter_config = LinterConfig(args)
|
linter_config = LinterConfig(args)
|
||||||
|
|
||||||
by_lang = linter_config.list_files(file_types=['py', 'sh', 'json', 'md', 'txt'],
|
by_lang = linter_config.list_files(
|
||||||
exclude=EXCLUDED_FILES)
|
file_types=['py', 'sh', 'json', 'md', 'txt'], exclude=EXCLUDED_FILES
|
||||||
|
)
|
||||||
|
|
||||||
linter_config.external_linter('mypy', [sys.executable, 'tools/run-mypy'], ['py'], pass_targets=False,
|
linter_config.external_linter(
|
||||||
description="Static type checker for Python (config: mypy.ini)")
|
'mypy',
|
||||||
linter_config.external_linter('flake8', ['flake8'], ['py'],
|
[sys.executable, 'tools/run-mypy'],
|
||||||
description="Standard Python linter (config: .flake8)")
|
['py'],
|
||||||
linter_config.external_linter('gitlint', ['tools/lint-commits'],
|
pass_targets=False,
|
||||||
description="Git Lint for commit messages")
|
description="Static type checker for Python (config: mypy.ini)",
|
||||||
|
)
|
||||||
|
linter_config.external_linter(
|
||||||
|
'flake8', ['flake8'], ['py'], description="Standard Python linter (config: .flake8)"
|
||||||
|
)
|
||||||
|
linter_config.external_linter(
|
||||||
|
'gitlint', ['tools/lint-commits'], description="Git Lint for commit messages"
|
||||||
|
)
|
||||||
|
|
||||||
@linter_config.lint
|
@linter_config.lint
|
||||||
def custom_py() -> int:
|
def custom_py() -> int:
|
||||||
|
@ -45,5 +54,6 @@ def run() -> None:
|
||||||
|
|
||||||
linter_config.do_lint()
|
linter_config.do_lint()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
run()
|
run()
|
||||||
|
|
|
@ -15,32 +15,39 @@ green = '\033[92m'
|
||||||
end_format = '\033[0m'
|
end_format = '\033[0m'
|
||||||
bold = '\033[1m'
|
bold = '\033[1m'
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
usage = """./tools/provision
|
usage = """./tools/provision
|
||||||
|
|
||||||
Creates a Python virtualenv. Its Python version is equal to
|
Creates a Python virtualenv. Its Python version is equal to
|
||||||
the Python version this command is executed with."""
|
the Python version this command is executed with."""
|
||||||
parser = argparse.ArgumentParser(usage=usage)
|
parser = argparse.ArgumentParser(usage=usage)
|
||||||
parser.add_argument('--python-interpreter', '-p',
|
parser.add_argument(
|
||||||
metavar='PATH_TO_PYTHON_INTERPRETER',
|
'--python-interpreter',
|
||||||
default=os.path.abspath(sys.executable),
|
'-p',
|
||||||
help='Path to the Python interpreter to use when provisioning.')
|
metavar='PATH_TO_PYTHON_INTERPRETER',
|
||||||
parser.add_argument('--force', '-f', action='store_true',
|
default=os.path.abspath(sys.executable),
|
||||||
help='create venv even with outdated Python version.')
|
help='Path to the Python interpreter to use when provisioning.',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--force', '-f', action='store_true', help='create venv even with outdated Python version.'
|
||||||
|
)
|
||||||
options = parser.parse_args()
|
options = parser.parse_args()
|
||||||
|
|
||||||
base_dir = os.path.abspath(os.path.join(__file__, '..', '..'))
|
base_dir = os.path.abspath(os.path.join(__file__, '..', '..'))
|
||||||
py_version_output = subprocess.check_output([options.python_interpreter, '--version'],
|
py_version_output = subprocess.check_output(
|
||||||
stderr=subprocess.STDOUT, universal_newlines=True)
|
[options.python_interpreter, '--version'], stderr=subprocess.STDOUT, universal_newlines=True
|
||||||
|
)
|
||||||
# The output has the format "Python 1.2.3"
|
# The output has the format "Python 1.2.3"
|
||||||
py_version_list = py_version_output.split()[1].split('.')
|
py_version_list = py_version_output.split()[1].split('.')
|
||||||
py_version = tuple(int(num) for num in py_version_list[0:2])
|
py_version = tuple(int(num) for num in py_version_list[0:2])
|
||||||
venv_name = 'zulip-api-py{}-venv'.format(py_version[0])
|
venv_name = 'zulip-api-py{}-venv'.format(py_version[0])
|
||||||
|
|
||||||
if py_version <= (3, 1) and (not options.force):
|
if py_version <= (3, 1) and (not options.force):
|
||||||
print(red + "Provision failed: Cannot create venv with outdated Python version ({}).\n"
|
print(
|
||||||
"Maybe try `python3 tools/provision`."
|
red + "Provision failed: Cannot create venv with outdated Python version ({}).\n"
|
||||||
.format(py_version_output.strip()) + end_format)
|
"Maybe try `python3 tools/provision`.".format(py_version_output.strip()) + end_format
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
venv_dir = os.path.join(base_dir, venv_name)
|
venv_dir = os.path.join(base_dir, venv_name)
|
||||||
|
@ -48,18 +55,24 @@ the Python version this command is executed with."""
|
||||||
try:
|
try:
|
||||||
return_code = subprocess.call([options.python_interpreter, '-m', 'venv', venv_dir])
|
return_code = subprocess.call([options.python_interpreter, '-m', 'venv', venv_dir])
|
||||||
except OSError:
|
except OSError:
|
||||||
print("{red}Installation with venv failed. Probable errors are: "
|
print(
|
||||||
"You are on Ubuntu and you haven't installed python3-venv,"
|
"{red}Installation with venv failed. Probable errors are: "
|
||||||
"or you are running an unsupported python version"
|
"You are on Ubuntu and you haven't installed python3-venv,"
|
||||||
"or python is not installed properly{end_format}"
|
"or you are running an unsupported python version"
|
||||||
.format(red=red, end_format=end_format))
|
"or python is not installed properly{end_format}".format(
|
||||||
|
red=red, end_format=end_format
|
||||||
|
)
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
# subprocess.call returns 0 if a script executed successfully
|
# subprocess.call returns 0 if a script executed successfully
|
||||||
if return_code:
|
if return_code:
|
||||||
raise OSError("The command `{} -m venv {}` failed. Virtualenv not created!"
|
raise OSError(
|
||||||
.format(options.python_interpreter, venv_dir))
|
"The command `{} -m venv {}` failed. Virtualenv not created!".format(
|
||||||
|
options.python_interpreter, venv_dir
|
||||||
|
)
|
||||||
|
)
|
||||||
print("New virtualenv created.")
|
print("New virtualenv created.")
|
||||||
else:
|
else:
|
||||||
print("Virtualenv already exists.")
|
print("Virtualenv already exists.")
|
||||||
|
@ -85,10 +98,21 @@ the Python version this command is executed with."""
|
||||||
pip_path = os.path.join(venv_dir, venv_exec_dir, 'pip')
|
pip_path = os.path.join(venv_dir, venv_exec_dir, 'pip')
|
||||||
# We first install a modern version of pip that supports --prefix
|
# We first install a modern version of pip that supports --prefix
|
||||||
subprocess.call([pip_path, 'install', 'pip>=9.0'])
|
subprocess.call([pip_path, 'install', 'pip>=9.0'])
|
||||||
if subprocess.call([pip_path, 'install', '--prefix', venv_dir, '-r',
|
if subprocess.call(
|
||||||
os.path.join(base_dir, requirements_filename)]):
|
[
|
||||||
raise OSError("The command `pip install -r {}` failed. Dependencies not installed!"
|
pip_path,
|
||||||
.format(os.path.join(base_dir, requirements_filename)))
|
'install',
|
||||||
|
'--prefix',
|
||||||
|
venv_dir,
|
||||||
|
'-r',
|
||||||
|
os.path.join(base_dir, requirements_filename),
|
||||||
|
]
|
||||||
|
):
|
||||||
|
raise OSError(
|
||||||
|
"The command `pip install -r {}` failed. Dependencies not installed!".format(
|
||||||
|
os.path.join(base_dir, requirements_filename)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
install_dependencies('requirements.txt')
|
install_dependencies('requirements.txt')
|
||||||
|
|
||||||
|
@ -105,10 +129,7 @@ the Python version this command is executed with."""
|
||||||
|
|
||||||
print(green + 'Success!' + end_format)
|
print(green + 'Success!' + end_format)
|
||||||
|
|
||||||
activate_command = os.path.join(base_dir,
|
activate_command = os.path.join(base_dir, venv_dir, venv_exec_dir, 'activate')
|
||||||
venv_dir,
|
|
||||||
venv_exec_dir,
|
|
||||||
'activate')
|
|
||||||
# We make the path look like a Unix path, because most Windows users
|
# We make the path look like a Unix path, because most Windows users
|
||||||
# are likely to be running in a bash shell.
|
# are likely to be running in a bash shell.
|
||||||
activate_command = activate_command.replace(os.sep, '/')
|
activate_command = activate_command.replace(os.sep, '/')
|
||||||
|
|
|
@ -13,6 +13,7 @@ import twine.commands.upload
|
||||||
|
|
||||||
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def cd(newdir):
|
def cd(newdir):
|
||||||
prevdir = os.getcwd()
|
prevdir = os.getcwd()
|
||||||
|
@ -22,6 +23,7 @@ def cd(newdir):
|
||||||
finally:
|
finally:
|
||||||
os.chdir(prevdir)
|
os.chdir(prevdir)
|
||||||
|
|
||||||
|
|
||||||
def _generate_dist(dist_type, setup_file, package_name, setup_args):
|
def _generate_dist(dist_type, setup_file, package_name, setup_args):
|
||||||
message = 'Generating {dist_type} for {package_name}.'.format(
|
message = 'Generating {dist_type} for {package_name}.'.format(
|
||||||
dist_type=dist_type,
|
dist_type=dist_type,
|
||||||
|
@ -40,13 +42,13 @@ def _generate_dist(dist_type, setup_file, package_name, setup_args):
|
||||||
)
|
)
|
||||||
print(crayons.green(message, bold=True))
|
print(crayons.green(message, bold=True))
|
||||||
|
|
||||||
|
|
||||||
def generate_bdist_wheel(setup_file, package_name, universal=False):
|
def generate_bdist_wheel(setup_file, package_name, universal=False):
|
||||||
if universal:
|
if universal:
|
||||||
_generate_dist('bdist_wheel', setup_file, package_name,
|
_generate_dist('bdist_wheel', setup_file, package_name, ['bdist_wheel', '--universal'])
|
||||||
['bdist_wheel', '--universal'])
|
|
||||||
else:
|
else:
|
||||||
_generate_dist('bdist_wheel', setup_file, package_name,
|
_generate_dist('bdist_wheel', setup_file, package_name, ['bdist_wheel'])
|
||||||
['bdist_wheel'])
|
|
||||||
|
|
||||||
def twine_upload(dist_dirs):
|
def twine_upload(dist_dirs):
|
||||||
message = 'Uploading distributions under the following directories:'
|
message = 'Uploading distributions under the following directories:'
|
||||||
|
@ -55,14 +57,12 @@ def twine_upload(dist_dirs):
|
||||||
print(crayons.yellow(dist_dir))
|
print(crayons.yellow(dist_dir))
|
||||||
twine.commands.upload.main(dist_dirs)
|
twine.commands.upload.main(dist_dirs)
|
||||||
|
|
||||||
|
|
||||||
def cleanup(package_dir):
|
def cleanup(package_dir):
|
||||||
build_dir = os.path.join(package_dir, 'build')
|
build_dir = os.path.join(package_dir, 'build')
|
||||||
temp_dir = os.path.join(package_dir, 'temp')
|
temp_dir = os.path.join(package_dir, 'temp')
|
||||||
dist_dir = os.path.join(package_dir, 'dist')
|
dist_dir = os.path.join(package_dir, 'dist')
|
||||||
egg_info = os.path.join(
|
egg_info = os.path.join(package_dir, '{}.egg-info'.format(os.path.basename(package_dir)))
|
||||||
package_dir,
|
|
||||||
'{}.egg-info'.format(os.path.basename(package_dir))
|
|
||||||
)
|
|
||||||
|
|
||||||
def _rm_if_it_exists(directory):
|
def _rm_if_it_exists(directory):
|
||||||
if os.path.isdir(directory):
|
if os.path.isdir(directory):
|
||||||
|
@ -74,6 +74,7 @@ def cleanup(package_dir):
|
||||||
_rm_if_it_exists(dist_dir)
|
_rm_if_it_exists(dist_dir)
|
||||||
_rm_if_it_exists(egg_info)
|
_rm_if_it_exists(egg_info)
|
||||||
|
|
||||||
|
|
||||||
def set_variable(fp, variable, value):
|
def set_variable(fp, variable, value):
|
||||||
fh, temp_abs_path = tempfile.mkstemp()
|
fh, temp_abs_path = tempfile.mkstemp()
|
||||||
with os.fdopen(fh, 'w') as new_file, open(fp) as old_file:
|
with os.fdopen(fh, 'w') as new_file, open(fp) as old_file:
|
||||||
|
@ -90,10 +91,10 @@ def set_variable(fp, variable, value):
|
||||||
os.remove(fp)
|
os.remove(fp)
|
||||||
shutil.move(temp_abs_path, fp)
|
shutil.move(temp_abs_path, fp)
|
||||||
|
|
||||||
message = 'Set {variable} in {fp} to {value}.'.format(
|
message = 'Set {variable} in {fp} to {value}.'.format(fp=fp, variable=variable, value=value)
|
||||||
fp=fp, variable=variable, value=value)
|
|
||||||
print(crayons.white(message, bold=True))
|
print(crayons.white(message, bold=True))
|
||||||
|
|
||||||
|
|
||||||
def update_requirements_in_zulip_repo(zulip_repo_dir, version, hash_or_tag):
|
def update_requirements_in_zulip_repo(zulip_repo_dir, version, hash_or_tag):
|
||||||
common = os.path.join(zulip_repo_dir, 'requirements', 'common.in')
|
common = os.path.join(zulip_repo_dir, 'requirements', 'common.in')
|
||||||
prod = os.path.join(zulip_repo_dir, 'requirements', 'prod.txt')
|
prod = os.path.join(zulip_repo_dir, 'requirements', 'prod.txt')
|
||||||
|
@ -115,10 +116,8 @@ def update_requirements_in_zulip_repo(zulip_repo_dir, version, hash_or_tag):
|
||||||
|
|
||||||
url_zulip = 'git+https://github.com/zulip/python-zulip-api.git@{tag}#egg={name}=={version}_git&subdirectory={name}\n'
|
url_zulip = 'git+https://github.com/zulip/python-zulip-api.git@{tag}#egg={name}=={version}_git&subdirectory={name}\n'
|
||||||
url_zulip_bots = 'git+https://github.com/zulip/python-zulip-api.git@{tag}#egg={name}=={version}+git&subdirectory={name}\n'
|
url_zulip_bots = 'git+https://github.com/zulip/python-zulip-api.git@{tag}#egg={name}=={version}+git&subdirectory={name}\n'
|
||||||
zulip_bots_line = url_zulip_bots.format(tag=hash_or_tag, name='zulip_bots',
|
zulip_bots_line = url_zulip_bots.format(tag=hash_or_tag, name='zulip_bots', version=version)
|
||||||
version=version)
|
zulip_line = url_zulip.format(tag=hash_or_tag, name='zulip', version=version)
|
||||||
zulip_line = url_zulip.format(tag=hash_or_tag, name='zulip',
|
|
||||||
version=version)
|
|
||||||
|
|
||||||
_edit_reqs_file(prod, zulip_bots_line, zulip_line)
|
_edit_reqs_file(prod, zulip_bots_line, zulip_line)
|
||||||
_edit_reqs_file(dev, zulip_bots_line, zulip_line)
|
_edit_reqs_file(dev, zulip_bots_line, zulip_line)
|
||||||
|
@ -135,6 +134,7 @@ def update_requirements_in_zulip_repo(zulip_repo_dir, version, hash_or_tag):
|
||||||
message = 'Updated zulip API package requirements in the main repo.'
|
message = 'Updated zulip API package requirements in the main repo.'
|
||||||
print(crayons.white(message, bold=True))
|
print(crayons.white(message, bold=True))
|
||||||
|
|
||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
usage = """
|
usage = """
|
||||||
Script to automate the PyPA release of the zulip, zulip_bots and
|
Script to automate the PyPA release of the zulip, zulip_bots and
|
||||||
|
@ -176,26 +176,36 @@ And you're done! Congrats!
|
||||||
"""
|
"""
|
||||||
parser = argparse.ArgumentParser(usage=usage)
|
parser = argparse.ArgumentParser(usage=usage)
|
||||||
|
|
||||||
parser.add_argument('--cleanup', '-c',
|
parser.add_argument(
|
||||||
action='store_true',
|
'--cleanup',
|
||||||
default=False,
|
'-c',
|
||||||
help='Remove build directories (dist/, build/, egg-info/, etc).')
|
action='store_true',
|
||||||
|
default=False,
|
||||||
|
help='Remove build directories (dist/, build/, egg-info/, etc).',
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument('--build', '-b',
|
parser.add_argument(
|
||||||
metavar='VERSION_NUM',
|
'--build',
|
||||||
help=('Build sdists and wheels for all packages with the'
|
'-b',
|
||||||
'specified version number.'
|
metavar='VERSION_NUM',
|
||||||
' sdists and wheels are stored in <package_name>/dist/*.'))
|
help=(
|
||||||
|
'Build sdists and wheels for all packages with the'
|
||||||
|
'specified version number.'
|
||||||
|
' sdists and wheels are stored in <package_name>/dist/*.'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument('--release', '-r',
|
parser.add_argument(
|
||||||
action='store_true',
|
'--release',
|
||||||
default=False,
|
'-r',
|
||||||
help='Upload the packages to PyPA using twine.')
|
action='store_true',
|
||||||
|
default=False,
|
||||||
|
help='Upload the packages to PyPA using twine.',
|
||||||
|
)
|
||||||
|
|
||||||
subparsers = parser.add_subparsers(dest='subcommand')
|
subparsers = parser.add_subparsers(dest='subcommand')
|
||||||
parser_main_repo = subparsers.add_parser(
|
parser_main_repo = subparsers.add_parser(
|
||||||
'update-main-repo',
|
'update-main-repo', help='Update the zulip/requirements/* in the main zulip repo.'
|
||||||
help='Update the zulip/requirements/* in the main zulip repo.'
|
|
||||||
)
|
)
|
||||||
parser_main_repo.add_argument('repo', metavar='PATH_TO_ZULIP_DIR')
|
parser_main_repo.add_argument('repo', metavar='PATH_TO_ZULIP_DIR')
|
||||||
parser_main_repo.add_argument('version', metavar='version number of the packages')
|
parser_main_repo.add_argument('version', metavar='version number of the packages')
|
||||||
|
@ -203,6 +213,7 @@ And you're done! Congrats!
|
||||||
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
options = parse_args()
|
options = parse_args()
|
||||||
|
|
||||||
|
@ -239,11 +250,10 @@ def main():
|
||||||
|
|
||||||
if options.subcommand == 'update-main-repo':
|
if options.subcommand == 'update-main-repo':
|
||||||
if options.hash:
|
if options.hash:
|
||||||
update_requirements_in_zulip_repo(options.repo, options.version,
|
update_requirements_in_zulip_repo(options.repo, options.version, options.hash)
|
||||||
options.hash)
|
|
||||||
else:
|
else:
|
||||||
update_requirements_in_zulip_repo(options.repo, options.version,
|
update_requirements_in_zulip_repo(options.repo, options.version, options.version)
|
||||||
options.version)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
11
tools/review
11
tools/review
|
@ -9,24 +9,29 @@ def exit(message: str) -> None:
|
||||||
print(message)
|
print(message)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def run(command: str) -> None:
|
def run(command: str) -> None:
|
||||||
print('\n>>> ' + command)
|
print('\n>>> ' + command)
|
||||||
subprocess.check_call(command.split())
|
subprocess.check_call(command.split())
|
||||||
|
|
||||||
|
|
||||||
def check_output(command: str) -> str:
|
def check_output(command: str) -> str:
|
||||||
return subprocess.check_output(command.split()).decode('ascii')
|
return subprocess.check_output(command.split()).decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
def get_git_branch() -> str:
|
def get_git_branch() -> str:
|
||||||
command = 'git rev-parse --abbrev-ref HEAD'
|
command = 'git rev-parse --abbrev-ref HEAD'
|
||||||
output = check_output(command)
|
output = check_output(command)
|
||||||
return output.strip()
|
return output.strip()
|
||||||
|
|
||||||
|
|
||||||
def check_git_pristine() -> None:
|
def check_git_pristine() -> None:
|
||||||
command = 'git status --porcelain'
|
command = 'git status --porcelain'
|
||||||
output = check_output(command)
|
output = check_output(command)
|
||||||
if output.strip():
|
if output.strip():
|
||||||
exit('Git is not pristine:\n' + output)
|
exit('Git is not pristine:\n' + output)
|
||||||
|
|
||||||
|
|
||||||
def ensure_on_clean_master() -> None:
|
def ensure_on_clean_master() -> None:
|
||||||
branch = get_git_branch()
|
branch = get_git_branch()
|
||||||
if branch != 'master':
|
if branch != 'master':
|
||||||
|
@ -35,6 +40,7 @@ def ensure_on_clean_master() -> None:
|
||||||
run('git fetch upstream master')
|
run('git fetch upstream master')
|
||||||
run('git rebase upstream/master')
|
run('git rebase upstream/master')
|
||||||
|
|
||||||
|
|
||||||
def create_pull_branch(pull_id: int) -> None:
|
def create_pull_branch(pull_id: int) -> None:
|
||||||
run('git fetch upstream pull/%d/head' % (pull_id,))
|
run('git fetch upstream pull/%d/head' % (pull_id,))
|
||||||
run('git checkout -B review-%s FETCH_HEAD' % (pull_id,))
|
run('git checkout -B review-%s FETCH_HEAD' % (pull_id,))
|
||||||
|
@ -44,8 +50,8 @@ def create_pull_branch(pull_id: int) -> None:
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print('PR: %d' % (pull_id,))
|
print('PR: %d' % (pull_id,))
|
||||||
print(subprocess.check_output(['git', 'log', 'HEAD~..',
|
print(subprocess.check_output(['git', 'log', 'HEAD~..', '--pretty=format:Author: %an']))
|
||||||
'--pretty=format:Author: %an']))
|
|
||||||
|
|
||||||
def review_pr() -> None:
|
def review_pr() -> None:
|
||||||
try:
|
try:
|
||||||
|
@ -56,5 +62,6 @@ def review_pr() -> None:
|
||||||
ensure_on_clean_master()
|
ensure_on_clean_master()
|
||||||
create_pull_branch(pull_id)
|
create_pull_branch(pull_id)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
review_pr()
|
review_pr()
|
||||||
|
|
|
@ -99,38 +99,78 @@ force_include = [
|
||||||
"zulip_bots/zulip_bots/bots/front/front.py",
|
"zulip_bots/zulip_bots/bots/front/front.py",
|
||||||
"zulip_bots/zulip_bots/bots/front/test_front.py",
|
"zulip_bots/zulip_bots/bots/front/test_front.py",
|
||||||
"tools/custom_check.py",
|
"tools/custom_check.py",
|
||||||
"tools/deploy"
|
"tools/deploy",
|
||||||
]
|
]
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.")
|
parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.")
|
||||||
parser.add_argument('targets', nargs='*', default=[],
|
parser.add_argument(
|
||||||
help="""files and directories to include in the result.
|
'targets',
|
||||||
If this is not specified, the current directory is used""")
|
nargs='*',
|
||||||
parser.add_argument('-m', '--modified', action='store_true', default=False, help='list only modified files')
|
default=[],
|
||||||
parser.add_argument('-a', '--all', dest='all', action='store_true', default=False,
|
help="""files and directories to include in the result.
|
||||||
help="""run mypy on all python files, ignoring the exclude list.
|
If this is not specified, the current directory is used""",
|
||||||
This is useful if you have to find out which files fail mypy check.""")
|
)
|
||||||
parser.add_argument('--no-disallow-untyped-defs', dest='disallow_untyped_defs', action='store_false', default=True,
|
parser.add_argument(
|
||||||
help="""Don't throw errors when functions are not annotated""")
|
'-m', '--modified', action='store_true', default=False, help='list only modified files'
|
||||||
parser.add_argument('--scripts-only', dest='scripts_only', action='store_true', default=False,
|
)
|
||||||
help="""Only type check extensionless python scripts""")
|
parser.add_argument(
|
||||||
parser.add_argument('--warn-unused-ignores', dest='warn_unused_ignores', action='store_true', default=False,
|
'-a',
|
||||||
help="""Use the --warn-unused-ignores flag with mypy""")
|
'--all',
|
||||||
parser.add_argument('--no-ignore-missing-imports', dest='ignore_missing_imports', action='store_false', default=True,
|
dest='all',
|
||||||
help="""Don't use the --ignore-missing-imports flag with mypy""")
|
action='store_true',
|
||||||
parser.add_argument('--quick', action='store_true', default=False,
|
default=False,
|
||||||
help="""Use the --quick flag with mypy""")
|
help="""run mypy on all python files, ignoring the exclude list.
|
||||||
|
This is useful if you have to find out which files fail mypy check.""",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--no-disallow-untyped-defs',
|
||||||
|
dest='disallow_untyped_defs',
|
||||||
|
action='store_false',
|
||||||
|
default=True,
|
||||||
|
help="""Don't throw errors when functions are not annotated""",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--scripts-only',
|
||||||
|
dest='scripts_only',
|
||||||
|
action='store_true',
|
||||||
|
default=False,
|
||||||
|
help="""Only type check extensionless python scripts""",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--warn-unused-ignores',
|
||||||
|
dest='warn_unused_ignores',
|
||||||
|
action='store_true',
|
||||||
|
default=False,
|
||||||
|
help="""Use the --warn-unused-ignores flag with mypy""",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--no-ignore-missing-imports',
|
||||||
|
dest='ignore_missing_imports',
|
||||||
|
action='store_false',
|
||||||
|
default=True,
|
||||||
|
help="""Don't use the --ignore-missing-imports flag with mypy""",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--quick', action='store_true', default=False, help="""Use the --quick flag with mypy"""
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.all:
|
if args.all:
|
||||||
exclude = []
|
exclude = []
|
||||||
|
|
||||||
# find all non-excluded files in current directory
|
# find all non-excluded files in current directory
|
||||||
files_dict = cast(Dict[str, List[str]],
|
files_dict = cast(
|
||||||
lister.list_files(targets=args.targets, ftypes=['py', 'pyi'],
|
Dict[str, List[str]],
|
||||||
use_shebang=True, modified_only=args.modified,
|
lister.list_files(
|
||||||
exclude = exclude + ['stubs'], group_by_ftype=True,
|
targets=args.targets,
|
||||||
extless_only=args.scripts_only))
|
ftypes=['py', 'pyi'],
|
||||||
|
use_shebang=True,
|
||||||
|
modified_only=args.modified,
|
||||||
|
exclude=exclude + ['stubs'],
|
||||||
|
group_by_ftype=True,
|
||||||
|
extless_only=args.scripts_only,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
for inpath in force_include:
|
for inpath in force_include:
|
||||||
try:
|
try:
|
||||||
|
@ -140,10 +180,13 @@ for inpath in force_include:
|
||||||
files_dict[ext].append(inpath)
|
files_dict[ext].append(inpath)
|
||||||
|
|
||||||
pyi_files = set(files_dict['pyi'])
|
pyi_files = set(files_dict['pyi'])
|
||||||
python_files = [fpath for fpath in files_dict['py']
|
python_files = [
|
||||||
if not fpath.endswith('.py') or fpath + 'i' not in pyi_files]
|
fpath for fpath in files_dict['py'] if not fpath.endswith('.py') or fpath + 'i' not in pyi_files
|
||||||
|
]
|
||||||
|
|
||||||
repo_python_files = OrderedDict([('zulip', []), ('zulip_bots', []), ('zulip_botserver', []), ('tools', [])])
|
repo_python_files = OrderedDict(
|
||||||
|
[('zulip', []), ('zulip_bots', []), ('zulip_botserver', []), ('tools', [])]
|
||||||
|
)
|
||||||
for file_path in python_files:
|
for file_path in python_files:
|
||||||
repo = PurePath(file_path).parts[0]
|
repo = PurePath(file_path).parts[0]
|
||||||
if repo in repo_python_files:
|
if repo in repo_python_files:
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
@ -10,21 +9,26 @@ import pytest
|
||||||
TOOLS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
TOOLS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
os.chdir(os.path.dirname(TOOLS_DIR))
|
os.chdir(os.path.dirname(TOOLS_DIR))
|
||||||
|
|
||||||
|
|
||||||
def handle_input_and_run_tests_for_package(package_name, path_list):
|
def handle_input_and_run_tests_for_package(package_name, path_list):
|
||||||
parser = argparse.ArgumentParser(description="Run tests for {}.".format(package_name))
|
parser = argparse.ArgumentParser(description="Run tests for {}.".format(package_name))
|
||||||
parser.add_argument('--coverage',
|
parser.add_argument(
|
||||||
nargs='?',
|
'--coverage',
|
||||||
const=True,
|
nargs='?',
|
||||||
default=False,
|
const=True,
|
||||||
help='compute test coverage (--coverage combine to combine with previous reports)')
|
default=False,
|
||||||
parser.add_argument('--pytest', '-p',
|
help='compute test coverage (--coverage combine to combine with previous reports)',
|
||||||
default=False,
|
)
|
||||||
action='store_true',
|
parser.add_argument(
|
||||||
help="run tests with pytest")
|
'--pytest', '-p', default=False, action='store_true', help="run tests with pytest"
|
||||||
parser.add_argument('--verbose', '-v',
|
)
|
||||||
default=False,
|
parser.add_argument(
|
||||||
action='store_true',
|
'--verbose',
|
||||||
help='show verbose output (with pytest)')
|
'-v',
|
||||||
|
default=False,
|
||||||
|
action='store_true',
|
||||||
|
help='show verbose output (with pytest)',
|
||||||
|
)
|
||||||
options = parser.parse_args()
|
options = parser.parse_args()
|
||||||
|
|
||||||
test_session_title = ' Running tests for {} '.format(package_name)
|
test_session_title = ' Running tests for {} '.format(package_name)
|
||||||
|
@ -33,6 +37,7 @@ def handle_input_and_run_tests_for_package(package_name, path_list):
|
||||||
|
|
||||||
if options.coverage:
|
if options.coverage:
|
||||||
import coverage
|
import coverage
|
||||||
|
|
||||||
cov = coverage.Coverage(config_file="tools/.coveragerc")
|
cov = coverage.Coverage(config_file="tools/.coveragerc")
|
||||||
if options.coverage == 'combine':
|
if options.coverage == 'combine':
|
||||||
cov.load()
|
cov.load()
|
||||||
|
@ -42,11 +47,11 @@ def handle_input_and_run_tests_for_package(package_name, path_list):
|
||||||
location_to_run_in = os.path.join(TOOLS_DIR, '..', *path_list)
|
location_to_run_in = os.path.join(TOOLS_DIR, '..', *path_list)
|
||||||
paths_to_test = ['.']
|
paths_to_test = ['.']
|
||||||
pytest_options = [
|
pytest_options = [
|
||||||
'-s', # show output from tests; this hides the progress bar though
|
'-s', # show output from tests; this hides the progress bar though
|
||||||
'-x', # stop on first test failure
|
'-x', # stop on first test failure
|
||||||
'--ff', # runs last failure first
|
'--ff', # runs last failure first
|
||||||
]
|
]
|
||||||
pytest_options += (['-v'] if options.verbose else [])
|
pytest_options += ['-v'] if options.verbose else []
|
||||||
os.chdir(location_to_run_in)
|
os.chdir(location_to_run_in)
|
||||||
result = pytest.main(paths_to_test + pytest_options)
|
result = pytest.main(paths_to_test + pytest_options)
|
||||||
if result != 0:
|
if result != 0:
|
||||||
|
|
|
@ -31,33 +31,37 @@ the tests for xkcd and wikipedia bots):
|
||||||
"""
|
"""
|
||||||
parser = argparse.ArgumentParser(description=description)
|
parser = argparse.ArgumentParser(description=description)
|
||||||
|
|
||||||
parser.add_argument('bots_to_test',
|
parser.add_argument(
|
||||||
metavar='bot',
|
'bots_to_test',
|
||||||
nargs='*',
|
metavar='bot',
|
||||||
default=[],
|
nargs='*',
|
||||||
help='specific bots to test (default is all)')
|
default=[],
|
||||||
parser.add_argument('--coverage',
|
help='specific bots to test (default is all)',
|
||||||
nargs='?',
|
)
|
||||||
const=True,
|
parser.add_argument(
|
||||||
default=False,
|
'--coverage',
|
||||||
help='compute test coverage (--coverage combine to combine with previous reports)')
|
nargs='?',
|
||||||
parser.add_argument('--exclude',
|
const=True,
|
||||||
metavar='bot',
|
default=False,
|
||||||
nargs='*',
|
help='compute test coverage (--coverage combine to combine with previous reports)',
|
||||||
default=[],
|
)
|
||||||
help='bot(s) to exclude')
|
parser.add_argument('--exclude', metavar='bot', nargs='*', default=[], help='bot(s) to exclude')
|
||||||
parser.add_argument('--error-on-no-init',
|
parser.add_argument(
|
||||||
default=False,
|
'--error-on-no-init',
|
||||||
action="store_true",
|
default=False,
|
||||||
help="whether to exit if a bot has tests which won't run due to no __init__.py")
|
action="store_true",
|
||||||
parser.add_argument('--pytest', '-p',
|
help="whether to exit if a bot has tests which won't run due to no __init__.py",
|
||||||
default=False,
|
)
|
||||||
action='store_true',
|
parser.add_argument(
|
||||||
help="run tests with pytest")
|
'--pytest', '-p', default=False, action='store_true', help="run tests with pytest"
|
||||||
parser.add_argument('--verbose', '-v',
|
)
|
||||||
default=False,
|
parser.add_argument(
|
||||||
action='store_true',
|
'--verbose',
|
||||||
help='show verbose output (with pytest)')
|
'-v',
|
||||||
|
default=False,
|
||||||
|
action='store_true',
|
||||||
|
help='show verbose output (with pytest)',
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,6 +80,7 @@ def main():
|
||||||
|
|
||||||
if options.coverage:
|
if options.coverage:
|
||||||
import coverage
|
import coverage
|
||||||
|
|
||||||
cov = coverage.Coverage(config_file="tools/.coveragerc")
|
cov = coverage.Coverage(config_file="tools/.coveragerc")
|
||||||
if options.coverage == 'combine':
|
if options.coverage == 'combine':
|
||||||
cov.load()
|
cov.load()
|
||||||
|
@ -94,11 +99,11 @@ def main():
|
||||||
excluded_bots = ['merels']
|
excluded_bots = ['merels']
|
||||||
pytest_bots_to_test = sorted([bot for bot in bots_to_test if bot not in excluded_bots])
|
pytest_bots_to_test = sorted([bot for bot in bots_to_test if bot not in excluded_bots])
|
||||||
pytest_options = [
|
pytest_options = [
|
||||||
'-s', # show output from tests; this hides the progress bar though
|
'-s', # show output from tests; this hides the progress bar though
|
||||||
'-x', # stop on first test failure
|
'-x', # stop on first test failure
|
||||||
'--ff', # runs last failure first
|
'--ff', # runs last failure first
|
||||||
]
|
]
|
||||||
pytest_options += (['-v'] if options.verbose else [])
|
pytest_options += ['-v'] if options.verbose else []
|
||||||
os.chdir(bots_dir)
|
os.chdir(bots_dir)
|
||||||
result = pytest.main(pytest_bots_to_test + pytest_options)
|
result = pytest.main(pytest_bots_to_test + pytest_options)
|
||||||
if result != 0:
|
if result != 0:
|
||||||
|
@ -116,7 +121,9 @@ def main():
|
||||||
test_suites.append(loader.discover(top_level + name, top_level_dir=top_level))
|
test_suites.append(loader.discover(top_level + name, top_level_dir=top_level))
|
||||||
except ImportError as exception:
|
except ImportError as exception:
|
||||||
print(exception)
|
print(exception)
|
||||||
print("This likely indicates that you need a '__init__.py' file in your bot directory.")
|
print(
|
||||||
|
"This likely indicates that you need a '__init__.py' file in your bot directory."
|
||||||
|
)
|
||||||
if options.error_on_no_init:
|
if options.error_on_no_init:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
@ -134,5 +141,6 @@ def main():
|
||||||
cov.html_report()
|
cov.html_report()
|
||||||
print("HTML report saved under directory 'htmlcov'.")
|
print("HTML report saved under directory 'htmlcov'.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
|
@ -4,11 +4,13 @@ config = {
|
||||||
"api_key": "key1",
|
"api_key": "key1",
|
||||||
"site": "https://realm1.zulipchat.com",
|
"site": "https://realm1.zulipchat.com",
|
||||||
"stream": "bridges",
|
"stream": "bridges",
|
||||||
"subject": "<- realm2"},
|
"subject": "<- realm2",
|
||||||
|
},
|
||||||
"bot_2": {
|
"bot_2": {
|
||||||
"email": "tunnel-bot@realm2.zulipchat.com",
|
"email": "tunnel-bot@realm2.zulipchat.com",
|
||||||
"api_key": "key2",
|
"api_key": "key2",
|
||||||
"site": "https://realm2.zulipchat.com",
|
"site": "https://realm2.zulipchat.com",
|
||||||
"stream": "bridges",
|
"stream": "bridges",
|
||||||
"subject": "<- realm1"}
|
"subject": "<- realm1",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,9 +11,9 @@ import interrealm_bridge_config
|
||||||
import zulip
|
import zulip
|
||||||
|
|
||||||
|
|
||||||
def create_pipe_event(to_client: zulip.Client, from_bot: Dict[str, Any],
|
def create_pipe_event(
|
||||||
to_bot: Dict[str, Any], stream_wide: bool
|
to_client: zulip.Client, from_bot: Dict[str, Any], to_bot: Dict[str, Any], stream_wide: bool
|
||||||
) -> Callable[[Dict[str, Any]], None]:
|
) -> Callable[[Dict[str, Any]], None]:
|
||||||
def _pipe_message(msg: Dict[str, Any]) -> None:
|
def _pipe_message(msg: Dict[str, Any]) -> None:
|
||||||
isa_stream = msg["type"] == "stream"
|
isa_stream = msg["type"] == "stream"
|
||||||
not_from_bot = msg["sender_email"] not in (from_bot["email"], to_bot["email"])
|
not_from_bot = msg["sender_email"] not in (from_bot["email"], to_bot["email"])
|
||||||
|
@ -32,8 +32,9 @@ def create_pipe_event(to_client: zulip.Client, from_bot: Dict[str, Any],
|
||||||
if "/user_uploads/" in msg["content"]:
|
if "/user_uploads/" in msg["content"]:
|
||||||
# Fix the upload URL of the image to be the source of where it
|
# Fix the upload URL of the image to be the source of where it
|
||||||
# comes from
|
# comes from
|
||||||
msg["content"] = msg["content"].replace("/user_uploads/",
|
msg["content"] = msg["content"].replace(
|
||||||
from_bot["site"] + "/user_uploads/")
|
"/user_uploads/", from_bot["site"] + "/user_uploads/"
|
||||||
|
)
|
||||||
if msg["content"].startswith(("```", "- ", "* ", "> ", "1. ")):
|
if msg["content"].startswith(("```", "- ", "* ", "> ", "1. ")):
|
||||||
# If a message starts with special prefixes, make sure to prepend a newline for
|
# If a message starts with special prefixes, make sure to prepend a newline for
|
||||||
# formatting purpose
|
# formatting purpose
|
||||||
|
@ -45,7 +46,7 @@ def create_pipe_event(to_client: zulip.Client, from_bot: Dict[str, Any],
|
||||||
"content": "**{}**: {}".format(msg["sender_full_name"], msg["content"]),
|
"content": "**{}**: {}".format(msg["sender_full_name"], msg["content"]),
|
||||||
"has_attachment": msg.get("has_attachment", False),
|
"has_attachment": msg.get("has_attachment", False),
|
||||||
"has_image": msg.get("has_image", False),
|
"has_image": msg.get("has_image", False),
|
||||||
"has_link": msg.get("has_link", False)
|
"has_link": msg.get("has_link", False),
|
||||||
}
|
}
|
||||||
print(msg_data)
|
print(msg_data)
|
||||||
print(to_client.send_message(msg_data))
|
print(to_client.send_message(msg_data))
|
||||||
|
@ -55,8 +56,10 @@ def create_pipe_event(to_client: zulip.Client, from_bot: Dict[str, Any],
|
||||||
if event["type"] == "message":
|
if event["type"] == "message":
|
||||||
msg = event["message"]
|
msg = event["message"]
|
||||||
_pipe_message(msg)
|
_pipe_message(msg)
|
||||||
|
|
||||||
return _pipe_event
|
return _pipe_event
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
usage = """run-interrealm-bridge [--stream]
|
usage = """run-interrealm-bridge [--stream]
|
||||||
|
|
||||||
|
@ -71,20 +74,15 @@ if __name__ == "__main__":
|
||||||
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(usage=usage)
|
parser = argparse.ArgumentParser(usage=usage)
|
||||||
parser.add_argument('--stream',
|
parser.add_argument('--stream', action='store_true', help="", default=False)
|
||||||
action='store_true',
|
|
||||||
help="",
|
|
||||||
default=False)
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
options = interrealm_bridge_config.config
|
options = interrealm_bridge_config.config
|
||||||
|
|
||||||
bot1 = options["bot_1"]
|
bot1 = options["bot_1"]
|
||||||
bot2 = options["bot_2"]
|
bot2 = options["bot_2"]
|
||||||
client1 = zulip.Client(email=bot1["email"], api_key=bot1["api_key"],
|
client1 = zulip.Client(email=bot1["email"], api_key=bot1["api_key"], site=bot1["site"])
|
||||||
site=bot1["site"])
|
client2 = zulip.Client(email=bot2["email"], api_key=bot2["api_key"], site=bot2["site"])
|
||||||
client2 = zulip.Client(email=bot2["email"], api_key=bot2["api_key"],
|
|
||||||
site=bot2["site"])
|
|
||||||
# A bidirectional tunnel
|
# A bidirectional tunnel
|
||||||
pipe_event1 = create_pipe_event(client2, bot1, bot2, args.stream)
|
pipe_event1 = create_pipe_event(client2, bot1, bot2, args.stream)
|
||||||
p1 = mp.Process(target=client1.call_on_each_event, args=(pipe_event1, ["message"]))
|
p1 = mp.Process(target=client1.call_on_each_event, args=(pipe_event1, ["message"]))
|
||||||
|
|
|
@ -26,7 +26,9 @@ Note that "_zulip" will be automatically appended to the IRC nick provided
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage), allow_provisioning=True)
|
parser = zulip.add_default_arguments(
|
||||||
|
argparse.ArgumentParser(usage=usage), allow_provisioning=True
|
||||||
|
)
|
||||||
parser.add_argument('--irc-server', default=None)
|
parser.add_argument('--irc-server', default=None)
|
||||||
parser.add_argument('--port', default=6667)
|
parser.add_argument('--port', default=6667)
|
||||||
parser.add_argument('--nick-prefix', default=None)
|
parser.add_argument('--nick-prefix', default=None)
|
||||||
|
@ -43,14 +45,24 @@ if __name__ == "__main__":
|
||||||
from irc_mirror_backend import IRCBot
|
from irc_mirror_backend import IRCBot
|
||||||
except ImportError:
|
except ImportError:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
print("You have unsatisfied dependencies. Install all missing dependencies with "
|
print(
|
||||||
"{} --provision".format(sys.argv[0]))
|
"You have unsatisfied dependencies. Install all missing dependencies with "
|
||||||
|
"{} --provision".format(sys.argv[0])
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if options.irc_server is None or options.nick_prefix is None or options.channel is None:
|
if options.irc_server is None or options.nick_prefix is None or options.channel is None:
|
||||||
parser.error("Missing required argument")
|
parser.error("Missing required argument")
|
||||||
|
|
||||||
nickname = options.nick_prefix + "_zulip"
|
nickname = options.nick_prefix + "_zulip"
|
||||||
bot = IRCBot(zulip_client, options.stream, options.topic, options.channel,
|
bot = IRCBot(
|
||||||
nickname, options.irc_server, options.nickserv_pw, options.port)
|
zulip_client,
|
||||||
|
options.stream,
|
||||||
|
options.topic,
|
||||||
|
options.channel,
|
||||||
|
nickname,
|
||||||
|
options.irc_server,
|
||||||
|
options.nickserv_pw,
|
||||||
|
options.port,
|
||||||
|
)
|
||||||
bot.start()
|
bot.start()
|
||||||
|
|
|
@ -10,8 +10,17 @@ from irc.client_aio import AioReactor
|
||||||
class IRCBot(irc.bot.SingleServerIRCBot):
|
class IRCBot(irc.bot.SingleServerIRCBot):
|
||||||
reactor_class = AioReactor
|
reactor_class = AioReactor
|
||||||
|
|
||||||
def __init__(self, zulip_client: Any, stream: str, topic: str, channel: irc.bot.Channel,
|
def __init__(
|
||||||
nickname: str, server: str, nickserv_password: str = '', port: int = 6667) -> None:
|
self,
|
||||||
|
zulip_client: Any,
|
||||||
|
stream: str,
|
||||||
|
topic: str,
|
||||||
|
channel: irc.bot.Channel,
|
||||||
|
nickname: str,
|
||||||
|
server: str,
|
||||||
|
nickserv_password: str = '',
|
||||||
|
port: int = 6667,
|
||||||
|
) -> None:
|
||||||
self.channel = channel # type: irc.bot.Channel
|
self.channel = channel # type: irc.bot.Channel
|
||||||
self.zulip_client = zulip_client
|
self.zulip_client = zulip_client
|
||||||
self.stream = stream
|
self.stream = stream
|
||||||
|
@ -31,9 +40,7 @@ class IRCBot(irc.bot.SingleServerIRCBot):
|
||||||
# Taken from
|
# Taken from
|
||||||
# https://github.com/jaraco/irc/blob/master/irc/client_aio.py,
|
# https://github.com/jaraco/irc/blob/master/irc/client_aio.py,
|
||||||
# in particular the method of AioSimpleIRCClient
|
# in particular the method of AioSimpleIRCClient
|
||||||
self.c = self.reactor.loop.run_until_complete(
|
self.c = self.reactor.loop.run_until_complete(self.connection.connect(*args, **kwargs))
|
||||||
self.connection.connect(*args, **kwargs)
|
|
||||||
)
|
|
||||||
print("Listening now. Please send an IRC message to verify operation")
|
print("Listening now. Please send an IRC message to verify operation")
|
||||||
|
|
||||||
def check_subscription_or_die(self) -> None:
|
def check_subscription_or_die(self) -> None:
|
||||||
|
@ -43,7 +50,10 @@ class IRCBot(irc.bot.SingleServerIRCBot):
|
||||||
exit(1)
|
exit(1)
|
||||||
subs = [s["name"] for s in resp["subscriptions"]]
|
subs = [s["name"] for s in resp["subscriptions"]]
|
||||||
if self.stream not in subs:
|
if self.stream not in subs:
|
||||||
print("The bot is not yet subscribed to stream '%s'. Please subscribe the bot to the stream first." % (self.stream,))
|
print(
|
||||||
|
"The bot is not yet subscribed to stream '%s'. Please subscribe the bot to the stream first."
|
||||||
|
% (self.stream,)
|
||||||
|
)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
def on_nicknameinuse(self, c: ServerConnection, e: Event) -> None:
|
def on_nicknameinuse(self, c: ServerConnection, e: Event) -> None:
|
||||||
|
@ -70,8 +80,11 @@ class IRCBot(irc.bot.SingleServerIRCBot):
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
recipients = [u["short_name"] for u in msg["display_recipient"] if
|
recipients = [
|
||||||
u["email"] != msg["sender_email"]]
|
u["short_name"]
|
||||||
|
for u in msg["display_recipient"]
|
||||||
|
if u["email"] != msg["sender_email"]
|
||||||
|
]
|
||||||
if len(recipients) == 1:
|
if len(recipients) == 1:
|
||||||
send = lambda x: self.c.privmsg(recipients[0], x)
|
send = lambda x: self.c.privmsg(recipients[0], x)
|
||||||
else:
|
else:
|
||||||
|
@ -89,12 +102,16 @@ class IRCBot(irc.bot.SingleServerIRCBot):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Forward the PM to Zulip
|
# Forward the PM to Zulip
|
||||||
print(self.zulip_client.send_message({
|
print(
|
||||||
"sender": sender,
|
self.zulip_client.send_message(
|
||||||
"type": "private",
|
{
|
||||||
"to": "username@example.com",
|
"sender": sender,
|
||||||
"content": content,
|
"type": "private",
|
||||||
}))
|
"to": "username@example.com",
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def on_pubmsg(self, c: ServerConnection, e: Event) -> None:
|
def on_pubmsg(self, c: ServerConnection, e: Event) -> None:
|
||||||
content = e.arguments[0]
|
content = e.arguments[0]
|
||||||
|
@ -103,12 +120,16 @@ class IRCBot(irc.bot.SingleServerIRCBot):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Forward the stream message to Zulip
|
# Forward the stream message to Zulip
|
||||||
print(self.zulip_client.send_message({
|
print(
|
||||||
"type": "stream",
|
self.zulip_client.send_message(
|
||||||
"to": self.stream,
|
{
|
||||||
"subject": self.topic,
|
"type": "stream",
|
||||||
"content": "**{}**: {}".format(sender, content),
|
"to": self.stream,
|
||||||
}))
|
"subject": self.topic,
|
||||||
|
"content": "**{}**: {}".format(sender, content),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def on_dccmsg(self, c: ServerConnection, e: Event) -> None:
|
def on_dccmsg(self, c: ServerConnection, e: Event) -> None:
|
||||||
c.privmsg("You said: " + e.arguments[0])
|
c.privmsg("You said: " + e.arguments[0])
|
||||||
|
|
|
@ -24,19 +24,22 @@ MATRIX_USERNAME_REGEX = '@([a-zA-Z0-9-_]+):matrix.org'
|
||||||
ZULIP_MESSAGE_TEMPLATE = "**{username}**: {message}"
|
ZULIP_MESSAGE_TEMPLATE = "**{username}**: {message}"
|
||||||
MATRIX_MESSAGE_TEMPLATE = "<{username}> {message}"
|
MATRIX_MESSAGE_TEMPLATE = "<{username}> {message}"
|
||||||
|
|
||||||
|
|
||||||
class Bridge_ConfigException(Exception):
|
class Bridge_ConfigException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Bridge_FatalMatrixException(Exception):
|
class Bridge_FatalMatrixException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Bridge_ZulipFatalException(Exception):
|
class Bridge_ZulipFatalException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def matrix_login(matrix_client: Any, matrix_config: Dict[str, Any]) -> None:
|
def matrix_login(matrix_client: Any, matrix_config: Dict[str, Any]) -> None:
|
||||||
try:
|
try:
|
||||||
matrix_client.login_with_password(matrix_config["username"],
|
matrix_client.login_with_password(matrix_config["username"], matrix_config["password"])
|
||||||
matrix_config["password"])
|
|
||||||
except MatrixRequestError as exception:
|
except MatrixRequestError as exception:
|
||||||
if exception.code == 403:
|
if exception.code == 403:
|
||||||
raise Bridge_FatalMatrixException("Bad username or password.")
|
raise Bridge_FatalMatrixException("Bad username or password.")
|
||||||
|
@ -45,6 +48,7 @@ def matrix_login(matrix_client: Any, matrix_config: Dict[str, Any]) -> None:
|
||||||
except MissingSchema:
|
except MissingSchema:
|
||||||
raise Bridge_FatalMatrixException("Bad URL format.")
|
raise Bridge_FatalMatrixException("Bad URL format.")
|
||||||
|
|
||||||
|
|
||||||
def matrix_join_room(matrix_client: Any, matrix_config: Dict[str, Any]) -> Any:
|
def matrix_join_room(matrix_client: Any, matrix_config: Dict[str, Any]) -> Any:
|
||||||
try:
|
try:
|
||||||
room = matrix_client.join_room(matrix_config["room_id"])
|
room = matrix_client.join_room(matrix_config["room_id"])
|
||||||
|
@ -55,10 +59,12 @@ def matrix_join_room(matrix_client: Any, matrix_config: Dict[str, Any]) -> Any:
|
||||||
else:
|
else:
|
||||||
raise Bridge_FatalMatrixException("Couldn't find room.")
|
raise Bridge_FatalMatrixException("Couldn't find room.")
|
||||||
|
|
||||||
|
|
||||||
def die(signal: int, frame: FrameType) -> None:
|
def die(signal: int, frame: FrameType) -> None:
|
||||||
# We actually want to exit, so run os._exit (so as not to be caught and restarted)
|
# We actually want to exit, so run os._exit (so as not to be caught and restarted)
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
|
|
||||||
|
|
||||||
def matrix_to_zulip(
|
def matrix_to_zulip(
|
||||||
zulip_client: zulip.Client,
|
zulip_client: zulip.Client,
|
||||||
zulip_config: Dict[str, Any],
|
zulip_config: Dict[str, Any],
|
||||||
|
@ -78,12 +84,14 @@ def matrix_to_zulip(
|
||||||
|
|
||||||
if not_from_zulip_bot and content:
|
if not_from_zulip_bot and content:
|
||||||
try:
|
try:
|
||||||
result = zulip_client.send_message({
|
result = zulip_client.send_message(
|
||||||
"type": "stream",
|
{
|
||||||
"to": zulip_config["stream"],
|
"type": "stream",
|
||||||
"subject": zulip_config["topic"],
|
"to": zulip_config["stream"],
|
||||||
"content": content,
|
"subject": zulip_config["topic"],
|
||||||
})
|
"content": content,
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as exception: # XXX This should be more specific
|
except Exception as exception: # XXX This should be more specific
|
||||||
# Generally raised when user is forbidden
|
# Generally raised when user is forbidden
|
||||||
raise Bridge_ZulipFatalException(exception)
|
raise Bridge_ZulipFatalException(exception)
|
||||||
|
@ -93,6 +101,7 @@ def matrix_to_zulip(
|
||||||
|
|
||||||
return _matrix_to_zulip
|
return _matrix_to_zulip
|
||||||
|
|
||||||
|
|
||||||
def get_message_content_from_event(event: Dict[str, Any], no_noise: bool) -> Optional[str]:
|
def get_message_content_from_event(event: Dict[str, Any], no_noise: bool) -> Optional[str]:
|
||||||
irc_nick = shorten_irc_nick(event['sender'])
|
irc_nick = shorten_irc_nick(event['sender'])
|
||||||
if event['type'] == "m.room.member":
|
if event['type'] == "m.room.member":
|
||||||
|
@ -101,19 +110,19 @@ def get_message_content_from_event(event: Dict[str, Any], no_noise: bool) -> Opt
|
||||||
# Join and leave events can be noisy. They are ignored by default.
|
# Join and leave events can be noisy. They are ignored by default.
|
||||||
# To enable these events pass `no_noise` as `False` as the script argument
|
# To enable these events pass `no_noise` as `False` as the script argument
|
||||||
if event['membership'] == "join":
|
if event['membership'] == "join":
|
||||||
content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick,
|
content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, message="joined")
|
||||||
message="joined")
|
|
||||||
elif event['membership'] == "leave":
|
elif event['membership'] == "leave":
|
||||||
content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick,
|
content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick, message="quit")
|
||||||
message="quit")
|
|
||||||
elif event['type'] == "m.room.message":
|
elif event['type'] == "m.room.message":
|
||||||
if event['content']['msgtype'] == "m.text" or event['content']['msgtype'] == "m.emote":
|
if event['content']['msgtype'] == "m.text" or event['content']['msgtype'] == "m.emote":
|
||||||
content = ZULIP_MESSAGE_TEMPLATE.format(username=irc_nick,
|
content = ZULIP_MESSAGE_TEMPLATE.format(
|
||||||
message=event['content']['body'])
|
username=irc_nick, message=event['content']['body']
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
content = event['type']
|
content = event['type']
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
def shorten_irc_nick(nick: str) -> str:
|
def shorten_irc_nick(nick: str) -> str:
|
||||||
"""
|
"""
|
||||||
Add nick shortner functions for specific IRC networks
|
Add nick shortner functions for specific IRC networks
|
||||||
|
@ -130,8 +139,8 @@ def shorten_irc_nick(nick: str) -> str:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
return nick
|
return nick
|
||||||
|
|
||||||
def zulip_to_matrix(config: Dict[str, Any], room: Any) -> Callable[[Dict[str, Any]], None]:
|
|
||||||
|
|
||||||
|
def zulip_to_matrix(config: Dict[str, Any], room: Any) -> Callable[[Dict[str, Any]], None]:
|
||||||
def _zulip_to_matrix(msg: Dict[str, Any]) -> None:
|
def _zulip_to_matrix(msg: Dict[str, Any]) -> None:
|
||||||
"""
|
"""
|
||||||
Zulip -> Matrix
|
Zulip -> Matrix
|
||||||
|
@ -139,12 +148,15 @@ def zulip_to_matrix(config: Dict[str, Any], room: Any) -> Callable[[Dict[str, An
|
||||||
message_valid = check_zulip_message_validity(msg, config)
|
message_valid = check_zulip_message_validity(msg, config)
|
||||||
if message_valid:
|
if message_valid:
|
||||||
matrix_username = msg["sender_full_name"].replace(' ', '')
|
matrix_username = msg["sender_full_name"].replace(' ', '')
|
||||||
matrix_text = MATRIX_MESSAGE_TEMPLATE.format(username=matrix_username,
|
matrix_text = MATRIX_MESSAGE_TEMPLATE.format(
|
||||||
message=msg["content"])
|
username=matrix_username, message=msg["content"]
|
||||||
|
)
|
||||||
# Forward Zulip message to Matrix
|
# Forward Zulip message to Matrix
|
||||||
room.send_text(matrix_text)
|
room.send_text(matrix_text)
|
||||||
|
|
||||||
return _zulip_to_matrix
|
return _zulip_to_matrix
|
||||||
|
|
||||||
|
|
||||||
def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> bool:
|
def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> bool:
|
||||||
is_a_stream = msg["type"] == "stream"
|
is_a_stream = msg["type"] == "stream"
|
||||||
in_the_specified_stream = msg["display_recipient"] == config["stream"]
|
in_the_specified_stream = msg["display_recipient"] == config["stream"]
|
||||||
|
@ -157,6 +169,7 @@ def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) ->
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def generate_parser() -> argparse.ArgumentParser:
|
def generate_parser() -> argparse.ArgumentParser:
|
||||||
description = """
|
description = """
|
||||||
Script to bridge between a topic in a Zulip stream, and a Matrix channel.
|
Script to bridge between a topic in a Zulip stream, and a Matrix channel.
|
||||||
|
@ -169,19 +182,34 @@ def generate_parser() -> argparse.ArgumentParser:
|
||||||
* #zulip:matrix.org (zulip channel on Matrix)
|
* #zulip:matrix.org (zulip channel on Matrix)
|
||||||
* #freenode_#zulip:matrix.org (zulip channel on irc.freenode.net)"""
|
* #freenode_#zulip:matrix.org (zulip channel on irc.freenode.net)"""
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description=description,
|
parser = argparse.ArgumentParser(
|
||||||
formatter_class=argparse.RawTextHelpFormatter)
|
description=description, formatter_class=argparse.RawTextHelpFormatter
|
||||||
parser.add_argument('-c', '--config', required=False,
|
)
|
||||||
help="Path to the config file for the bridge.")
|
parser.add_argument(
|
||||||
parser.add_argument('--write-sample-config', metavar='PATH', dest='sample_config',
|
'-c', '--config', required=False, help="Path to the config file for the bridge."
|
||||||
help="Generate a configuration template at the specified location.")
|
)
|
||||||
parser.add_argument('--from-zuliprc', metavar='ZULIPRC', dest='zuliprc',
|
parser.add_argument(
|
||||||
help="Optional path to zuliprc file for bot, when using --write-sample-config")
|
'--write-sample-config',
|
||||||
parser.add_argument('--show-join-leave', dest='no_noise',
|
metavar='PATH',
|
||||||
default=True, action='store_false',
|
dest='sample_config',
|
||||||
help="Enable IRC join/leave events.")
|
help="Generate a configuration template at the specified location.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--from-zuliprc',
|
||||||
|
metavar='ZULIPRC',
|
||||||
|
dest='zuliprc',
|
||||||
|
help="Optional path to zuliprc file for bot, when using --write-sample-config",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--show-join-leave',
|
||||||
|
dest='no_noise',
|
||||||
|
default=True,
|
||||||
|
action='store_false',
|
||||||
|
help="Enable IRC join/leave events.",
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def read_configuration(config_file: str) -> Dict[str, Dict[str, str]]:
|
def read_configuration(config_file: str) -> Dict[str, Dict[str, str]]:
|
||||||
config = configparser.ConfigParser()
|
config = configparser.ConfigParser()
|
||||||
|
|
||||||
|
@ -197,25 +225,40 @@ def read_configuration(config_file: str) -> Dict[str, Dict[str, str]]:
|
||||||
|
|
||||||
return {section: dict(config[section]) for section in config.sections()}
|
return {section: dict(config[section]) for section in config.sections()}
|
||||||
|
|
||||||
|
|
||||||
def write_sample_config(target_path: str, zuliprc: Optional[str]) -> None:
|
def write_sample_config(target_path: str, zuliprc: Optional[str]) -> None:
|
||||||
if os.path.exists(target_path):
|
if os.path.exists(target_path):
|
||||||
raise Bridge_ConfigException("Path '{}' exists; not overwriting existing file.".format(target_path))
|
raise Bridge_ConfigException(
|
||||||
|
"Path '{}' exists; not overwriting existing file.".format(target_path)
|
||||||
|
)
|
||||||
|
|
||||||
sample_dict = OrderedDict((
|
sample_dict = OrderedDict(
|
||||||
('matrix', OrderedDict((
|
(
|
||||||
('host', 'https://matrix.org'),
|
(
|
||||||
('username', 'username'),
|
'matrix',
|
||||||
('password', 'password'),
|
OrderedDict(
|
||||||
('room_id', '#zulip:matrix.org'),
|
(
|
||||||
))),
|
('host', 'https://matrix.org'),
|
||||||
('zulip', OrderedDict((
|
('username', 'username'),
|
||||||
('email', 'glitch-bot@chat.zulip.org'),
|
('password', 'password'),
|
||||||
('api_key', 'aPiKeY'),
|
('room_id', '#zulip:matrix.org'),
|
||||||
('site', 'https://chat.zulip.org'),
|
)
|
||||||
('stream', 'test here'),
|
),
|
||||||
('topic', 'matrix'),
|
),
|
||||||
))),
|
(
|
||||||
))
|
'zulip',
|
||||||
|
OrderedDict(
|
||||||
|
(
|
||||||
|
('email', 'glitch-bot@chat.zulip.org'),
|
||||||
|
('api_key', 'aPiKeY'),
|
||||||
|
('site', 'https://chat.zulip.org'),
|
||||||
|
('stream', 'test here'),
|
||||||
|
('topic', 'matrix'),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if zuliprc is not None:
|
if zuliprc is not None:
|
||||||
if not os.path.exists(zuliprc):
|
if not os.path.exists(zuliprc):
|
||||||
|
@ -238,6 +281,7 @@ def write_sample_config(target_path: str, zuliprc: Optional[str]) -> None:
|
||||||
with open(target_path, 'w') as target:
|
with open(target_path, 'w') as target:
|
||||||
sample.write(target)
|
sample.write(target)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
signal.signal(signal.SIGINT, die)
|
signal.signal(signal.SIGINT, die)
|
||||||
logging.basicConfig(level=logging.WARNING)
|
logging.basicConfig(level=logging.WARNING)
|
||||||
|
@ -254,8 +298,11 @@ def main() -> None:
|
||||||
if options.zuliprc is None:
|
if options.zuliprc is None:
|
||||||
print("Wrote sample configuration to '{}'".format(options.sample_config))
|
print("Wrote sample configuration to '{}'".format(options.sample_config))
|
||||||
else:
|
else:
|
||||||
print("Wrote sample configuration to '{}' using zuliprc file '{}'"
|
print(
|
||||||
.format(options.sample_config, options.zuliprc))
|
"Wrote sample configuration to '{}' using zuliprc file '{}'".format(
|
||||||
|
options.sample_config, options.zuliprc
|
||||||
|
)
|
||||||
|
)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
elif not options.config:
|
elif not options.config:
|
||||||
print("Options required: -c or --config to run, OR --write-sample-config.")
|
print("Options required: -c or --config to run, OR --write-sample-config.")
|
||||||
|
@ -277,9 +324,11 @@ def main() -> None:
|
||||||
while backoff.keep_going():
|
while backoff.keep_going():
|
||||||
print("Starting matrix mirroring bot")
|
print("Starting matrix mirroring bot")
|
||||||
try:
|
try:
|
||||||
zulip_client = zulip.Client(email=zulip_config["email"],
|
zulip_client = zulip.Client(
|
||||||
api_key=zulip_config["api_key"],
|
email=zulip_config["email"],
|
||||||
site=zulip_config["site"])
|
api_key=zulip_config["api_key"],
|
||||||
|
site=zulip_config["site"],
|
||||||
|
)
|
||||||
matrix_client = MatrixClient(matrix_config["host"])
|
matrix_client = MatrixClient(matrix_config["host"])
|
||||||
|
|
||||||
# Login to Matrix
|
# Login to Matrix
|
||||||
|
@ -287,8 +336,9 @@ def main() -> None:
|
||||||
# Join a room in Matrix
|
# Join a room in Matrix
|
||||||
room = matrix_join_room(matrix_client, matrix_config)
|
room = matrix_join_room(matrix_client, matrix_config)
|
||||||
|
|
||||||
room.add_listener(matrix_to_zulip(zulip_client, zulip_config, matrix_config,
|
room.add_listener(
|
||||||
options.no_noise))
|
matrix_to_zulip(zulip_client, zulip_config, matrix_config, options.no_noise)
|
||||||
|
)
|
||||||
|
|
||||||
print("Starting listener thread on Matrix client")
|
print("Starting listener thread on Matrix client")
|
||||||
matrix_client.start_listener_thread()
|
matrix_client.start_listener_thread()
|
||||||
|
@ -306,5 +356,6 @@ def main() -> None:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
backoff.fail()
|
backoff.fail()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
|
@ -30,22 +30,26 @@ topic = matrix
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def new_temp_dir() -> Iterator[str]:
|
def new_temp_dir() -> Iterator[str]:
|
||||||
path = mkdtemp()
|
path = mkdtemp()
|
||||||
yield path
|
yield path
|
||||||
shutil.rmtree(path)
|
shutil.rmtree(path)
|
||||||
|
|
||||||
|
|
||||||
class MatrixBridgeScriptTests(TestCase):
|
class MatrixBridgeScriptTests(TestCase):
|
||||||
def output_from_script(self, options: List[str]) -> List[str]:
|
def output_from_script(self, options: List[str]) -> List[str]:
|
||||||
popen = Popen(["python", script] + options, stdin=PIPE, stdout=PIPE, universal_newlines=True)
|
popen = Popen(
|
||||||
|
["python", script] + options, stdin=PIPE, stdout=PIPE, universal_newlines=True
|
||||||
|
)
|
||||||
return popen.communicate()[0].strip().split("\n")
|
return popen.communicate()[0].strip().split("\n")
|
||||||
|
|
||||||
def test_no_args(self) -> None:
|
def test_no_args(self) -> None:
|
||||||
output_lines = self.output_from_script([])
|
output_lines = self.output_from_script([])
|
||||||
expected_lines = [
|
expected_lines = [
|
||||||
"Options required: -c or --config to run, OR --write-sample-config.",
|
"Options required: -c or --config to run, OR --write-sample-config.",
|
||||||
"usage: {} [-h]".format(script_file)
|
"usage: {} [-h]".format(script_file),
|
||||||
]
|
]
|
||||||
for expected, output in zip(expected_lines, output_lines):
|
for expected, output in zip(expected_lines, output_lines):
|
||||||
self.assertIn(expected, output)
|
self.assertIn(expected, output)
|
||||||
|
@ -74,19 +78,27 @@ class MatrixBridgeScriptTests(TestCase):
|
||||||
|
|
||||||
def test_write_sample_config_from_zuliprc(self) -> None:
|
def test_write_sample_config_from_zuliprc(self) -> None:
|
||||||
zuliprc_template = ["[api]", "email={email}", "key={key}", "site={site}"]
|
zuliprc_template = ["[api]", "email={email}", "key={key}", "site={site}"]
|
||||||
zulip_params = {'email': 'foo@bar',
|
zulip_params = {
|
||||||
'key': 'some_api_key',
|
'email': 'foo@bar',
|
||||||
'site': 'https://some.chat.serverplace'}
|
'key': 'some_api_key',
|
||||||
|
'site': 'https://some.chat.serverplace',
|
||||||
|
}
|
||||||
with new_temp_dir() as tempdir:
|
with new_temp_dir() as tempdir:
|
||||||
path = os.path.join(tempdir, sample_config_path)
|
path = os.path.join(tempdir, sample_config_path)
|
||||||
zuliprc_path = os.path.join(tempdir, "zuliprc")
|
zuliprc_path = os.path.join(tempdir, "zuliprc")
|
||||||
with open(zuliprc_path, "w") as zuliprc_file:
|
with open(zuliprc_path, "w") as zuliprc_file:
|
||||||
zuliprc_file.write("\n".join(zuliprc_template).format(**zulip_params))
|
zuliprc_file.write("\n".join(zuliprc_template).format(**zulip_params))
|
||||||
output_lines = self.output_from_script(["--write-sample-config", path,
|
output_lines = self.output_from_script(
|
||||||
"--from-zuliprc", zuliprc_path])
|
["--write-sample-config", path, "--from-zuliprc", zuliprc_path]
|
||||||
self.assertEqual(output_lines,
|
)
|
||||||
["Wrote sample configuration to '{}' using zuliprc file '{}'"
|
self.assertEqual(
|
||||||
.format(path, zuliprc_path)])
|
output_lines,
|
||||||
|
[
|
||||||
|
"Wrote sample configuration to '{}' using zuliprc file '{}'".format(
|
||||||
|
path, zuliprc_path
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
with open(path) as sample_file:
|
with open(path) as sample_file:
|
||||||
sample_lines = [line.strip() for line in sample_file.readlines()]
|
sample_lines = [line.strip() for line in sample_file.readlines()]
|
||||||
|
@ -101,23 +113,26 @@ class MatrixBridgeScriptTests(TestCase):
|
||||||
path = os.path.join(tempdir, sample_config_path)
|
path = os.path.join(tempdir, sample_config_path)
|
||||||
zuliprc_path = os.path.join(tempdir, "zuliprc")
|
zuliprc_path = os.path.join(tempdir, "zuliprc")
|
||||||
# No writing of zuliprc file here -> triggers check for zuliprc absence
|
# No writing of zuliprc file here -> triggers check for zuliprc absence
|
||||||
output_lines = self.output_from_script(["--write-sample-config", path,
|
output_lines = self.output_from_script(
|
||||||
"--from-zuliprc", zuliprc_path])
|
["--write-sample-config", path, "--from-zuliprc", zuliprc_path]
|
||||||
self.assertEqual(output_lines,
|
)
|
||||||
["Could not write sample config: Zuliprc file '{}' does not exist."
|
self.assertEqual(
|
||||||
.format(zuliprc_path)])
|
output_lines,
|
||||||
|
[
|
||||||
|
"Could not write sample config: Zuliprc file '{}' does not exist.".format(
|
||||||
|
zuliprc_path
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MatrixBridgeZulipToMatrixTests(TestCase):
|
class MatrixBridgeZulipToMatrixTests(TestCase):
|
||||||
valid_zulip_config = dict(
|
valid_zulip_config = dict(stream="some stream", topic="some topic", email="some@email")
|
||||||
stream="some stream",
|
|
||||||
topic="some topic",
|
|
||||||
email="some@email"
|
|
||||||
)
|
|
||||||
valid_msg = dict(
|
valid_msg = dict(
|
||||||
sender_email="John@Smith.smith", # must not be equal to config:email
|
sender_email="John@Smith.smith", # must not be equal to config:email
|
||||||
type="stream", # Can only mirror Zulip streams
|
type="stream", # Can only mirror Zulip streams
|
||||||
display_recipient=valid_zulip_config['stream'],
|
display_recipient=valid_zulip_config['stream'],
|
||||||
subject=valid_zulip_config['topic']
|
subject=valid_zulip_config['topic'],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_zulip_message_validity_success(self) -> None:
|
def test_zulip_message_validity_success(self) -> None:
|
||||||
|
|
|
@ -10,5 +10,5 @@ config = {
|
||||||
"username": "slack username",
|
"username": "slack username",
|
||||||
"token": "slack token",
|
"token": "slack token",
|
||||||
"channel": "C5Z5N7R8A -- must be channel id",
|
"channel": "C5Z5N7R8A -- must be channel id",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import zulip
|
||||||
ZULIP_MESSAGE_TEMPLATE = "**{username}**: {message}"
|
ZULIP_MESSAGE_TEMPLATE = "**{username}**: {message}"
|
||||||
SLACK_MESSAGE_TEMPLATE = "<{username}> {message}"
|
SLACK_MESSAGE_TEMPLATE = "<{username}> {message}"
|
||||||
|
|
||||||
|
|
||||||
def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> bool:
|
def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) -> bool:
|
||||||
is_a_stream = msg["type"] == "stream"
|
is_a_stream = msg["type"] == "stream"
|
||||||
in_the_specified_stream = msg["display_recipient"] == config["stream"]
|
in_the_specified_stream = msg["display_recipient"] == config["stream"]
|
||||||
|
@ -30,6 +31,7 @@ def check_zulip_message_validity(msg: Dict[str, Any], config: Dict[str, Any]) ->
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class SlackBridge:
|
class SlackBridge:
|
||||||
def __init__(self, config: Dict[str, Any]) -> None:
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
|
@ -40,7 +42,8 @@ class SlackBridge:
|
||||||
self.zulip_client = zulip.Client(
|
self.zulip_client = zulip.Client(
|
||||||
email=self.zulip_config["email"],
|
email=self.zulip_config["email"],
|
||||||
api_key=self.zulip_config["api_key"],
|
api_key=self.zulip_config["api_key"],
|
||||||
site=self.zulip_config["site"])
|
site=self.zulip_config["site"],
|
||||||
|
)
|
||||||
self.zulip_stream = self.zulip_config["stream"]
|
self.zulip_stream = self.zulip_config["stream"]
|
||||||
self.zulip_subject = self.zulip_config["topic"]
|
self.zulip_subject = self.zulip_config["topic"]
|
||||||
|
|
||||||
|
@ -69,18 +72,22 @@ class SlackBridge:
|
||||||
message_valid = check_zulip_message_validity(msg, self.zulip_config)
|
message_valid = check_zulip_message_validity(msg, self.zulip_config)
|
||||||
if message_valid:
|
if message_valid:
|
||||||
self.wrap_slack_mention_with_bracket(msg)
|
self.wrap_slack_mention_with_bracket(msg)
|
||||||
slack_text = SLACK_MESSAGE_TEMPLATE.format(username=msg["sender_full_name"],
|
slack_text = SLACK_MESSAGE_TEMPLATE.format(
|
||||||
message=msg["content"])
|
username=msg["sender_full_name"], message=msg["content"]
|
||||||
|
)
|
||||||
self.slack_webclient.chat_postMessage(
|
self.slack_webclient.chat_postMessage(
|
||||||
channel=self.channel,
|
channel=self.channel,
|
||||||
text=slack_text,
|
text=slack_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
return _zulip_to_slack
|
return _zulip_to_slack
|
||||||
|
|
||||||
def run_slack_listener(self) -> None:
|
def run_slack_listener(self) -> None:
|
||||||
members = self.slack_webclient.users_list()['members']
|
members = self.slack_webclient.users_list()['members']
|
||||||
# See also https://api.slack.com/changelog/2017-09-the-one-about-usernames
|
# See also https://api.slack.com/changelog/2017-09-the-one-about-usernames
|
||||||
self.slack_id_to_name = {u["id"]: u["profile"].get("display_name", u["profile"]["real_name"]) for u in members}
|
self.slack_id_to_name = {
|
||||||
|
u["id"]: u["profile"].get("display_name", u["profile"]["real_name"]) for u in members
|
||||||
|
}
|
||||||
self.slack_name_to_id = {v: k for k, v in self.slack_id_to_name.items()}
|
self.slack_name_to_id = {v: k for k, v in self.slack_id_to_name.items()}
|
||||||
|
|
||||||
@RTMClient.run_on(event='message')
|
@RTMClient.run_on(event='message')
|
||||||
|
@ -96,14 +103,13 @@ class SlackBridge:
|
||||||
self.replace_slack_id_with_name(msg)
|
self.replace_slack_id_with_name(msg)
|
||||||
content = ZULIP_MESSAGE_TEMPLATE.format(username=user, message=msg['text'])
|
content = ZULIP_MESSAGE_TEMPLATE.format(username=user, message=msg['text'])
|
||||||
msg_data = dict(
|
msg_data = dict(
|
||||||
type="stream",
|
type="stream", to=self.zulip_stream, subject=self.zulip_subject, content=content
|
||||||
to=self.zulip_stream,
|
)
|
||||||
subject=self.zulip_subject,
|
|
||||||
content=content)
|
|
||||||
self.zulip_client.send_message(msg_data)
|
self.zulip_client.send_message(msg_data)
|
||||||
|
|
||||||
self.slack_client.start()
|
self.slack_client.start()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
usage = """run-slack-bridge
|
usage = """run-slack-bridge
|
||||||
|
|
||||||
|
@ -124,7 +130,9 @@ if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
sb = SlackBridge(config)
|
sb = SlackBridge(config)
|
||||||
|
|
||||||
zp = threading.Thread(target=sb.zulip_client.call_on_each_message, args=(sb.zulip_to_slack(),))
|
zp = threading.Thread(
|
||||||
|
target=sb.zulip_client.call_on_each_message, args=(sb.zulip_to_slack(),)
|
||||||
|
)
|
||||||
sp = threading.Thread(target=sb.run_slack_listener, args=())
|
sp = threading.Thread(target=sb.run_slack_listener, args=())
|
||||||
print("Starting message handler on Zulip client")
|
print("Starting message handler on Zulip client")
|
||||||
zp.start()
|
zp.start()
|
||||||
|
|
|
@ -39,7 +39,8 @@ client = zulip.Client(
|
||||||
email=config.ZULIP_USER,
|
email=config.ZULIP_USER,
|
||||||
site=config.ZULIP_SITE,
|
site=config.ZULIP_SITE,
|
||||||
api_key=config.ZULIP_API_KEY,
|
api_key=config.ZULIP_API_KEY,
|
||||||
client="ZulipCodebase/" + VERSION)
|
client="ZulipCodebase/" + VERSION,
|
||||||
|
)
|
||||||
user_agent = "Codebase To Zulip Mirroring script (zulip-devel@googlegroups.com)"
|
user_agent = "Codebase To Zulip Mirroring script (zulip-devel@googlegroups.com)"
|
||||||
|
|
||||||
# find some form of JSON loader/dumper, with a preference order for speed.
|
# find some form of JSON loader/dumper, with a preference order for speed.
|
||||||
|
@ -52,13 +53,18 @@ while len(json_implementations):
|
||||||
except ImportError:
|
except ImportError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
def make_api_call(path: str) -> Optional[List[Dict[str, Any]]]:
|
def make_api_call(path: str) -> Optional[List[Dict[str, Any]]]:
|
||||||
response = requests.get("https://api3.codebasehq.com/%s" % (path,),
|
response = requests.get(
|
||||||
auth=(config.CODEBASE_API_USERNAME, config.CODEBASE_API_KEY),
|
"https://api3.codebasehq.com/%s" % (path,),
|
||||||
params={'raw': 'True'},
|
auth=(config.CODEBASE_API_USERNAME, config.CODEBASE_API_KEY),
|
||||||
headers = {"User-Agent": user_agent,
|
params={'raw': 'True'},
|
||||||
"Content-Type": "application/json",
|
headers={
|
||||||
"Accept": "application/json"})
|
"User-Agent": user_agent,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return json.loads(response.text)
|
return json.loads(response.text)
|
||||||
|
|
||||||
|
@ -69,12 +75,16 @@ def make_api_call(path: str) -> Optional[List[Dict[str, Any]]]:
|
||||||
logging.error("Bad authorization from Codebase. Please check your credentials")
|
logging.error("Bad authorization from Codebase. Please check your credentials")
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
else:
|
else:
|
||||||
logging.warn("Found non-success response status code: %s %s" % (response.status_code, response.text))
|
logging.warn(
|
||||||
|
"Found non-success response status code: %s %s" % (response.status_code, response.text)
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def make_url(path: str) -> str:
|
def make_url(path: str) -> str:
|
||||||
return "%s/%s" % (config.CODEBASE_ROOT_URL, path)
|
return "%s/%s" % (config.CODEBASE_ROOT_URL, path)
|
||||||
|
|
||||||
|
|
||||||
def handle_event(event: Dict[str, Any]) -> None:
|
def handle_event(event: Dict[str, Any]) -> None:
|
||||||
event = event['event']
|
event = event['event']
|
||||||
event_type = event['type']
|
event_type = event['type']
|
||||||
|
@ -114,11 +124,17 @@ def handle_event(event: Dict[str, Any]) -> None:
|
||||||
else:
|
else:
|
||||||
if new_ref:
|
if new_ref:
|
||||||
branch = "new branch %s" % (branch,)
|
branch = "new branch %s" % (branch,)
|
||||||
content = ("%s pushed %s commit(s) to %s in project %s:\n\n" %
|
content = "%s pushed %s commit(s) to %s in project %s:\n\n" % (
|
||||||
(actor_name, num_commits, branch, project))
|
actor_name,
|
||||||
|
num_commits,
|
||||||
|
branch,
|
||||||
|
project,
|
||||||
|
)
|
||||||
for commit in raw_props.get('commits'):
|
for commit in raw_props.get('commits'):
|
||||||
ref = commit.get('ref')
|
ref = commit.get('ref')
|
||||||
url = make_url("projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, ref))
|
url = make_url(
|
||||||
|
"projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, ref)
|
||||||
|
)
|
||||||
message = commit.get('message')
|
message = commit.get('message')
|
||||||
content += "* [%s](%s): %s\n" % (ref, url, message)
|
content += "* [%s](%s): %s\n" % (ref, url, message)
|
||||||
elif event_type == 'ticketing_ticket':
|
elif event_type == 'ticketing_ticket':
|
||||||
|
@ -133,8 +149,10 @@ def handle_event(event: Dict[str, Any]) -> None:
|
||||||
if assignee is None:
|
if assignee is None:
|
||||||
assignee = "no one"
|
assignee = "no one"
|
||||||
subject = "#%s: %s" % (num, name)
|
subject = "#%s: %s" % (num, name)
|
||||||
content = ("""%s created a new ticket [#%s](%s) priority **%s** assigned to %s:\n\n~~~ quote\n %s""" %
|
content = (
|
||||||
(actor_name, num, url, priority, assignee, name))
|
"""%s created a new ticket [#%s](%s) priority **%s** assigned to %s:\n\n~~~ quote\n %s"""
|
||||||
|
% (actor_name, num, url, priority, assignee, name)
|
||||||
|
)
|
||||||
elif event_type == 'ticketing_note':
|
elif event_type == 'ticketing_note':
|
||||||
stream = config.ZULIP_TICKETS_STREAM_NAME
|
stream = config.ZULIP_TICKETS_STREAM_NAME
|
||||||
|
|
||||||
|
@ -148,11 +166,19 @@ def handle_event(event: Dict[str, Any]) -> None:
|
||||||
|
|
||||||
content = ""
|
content = ""
|
||||||
if body is not None and len(body) > 0:
|
if body is not None and len(body) > 0:
|
||||||
content = "%s added a comment to ticket [#%s](%s):\n\n~~~ quote\n%s\n\n" % (actor_name, num, url, body)
|
content = "%s added a comment to ticket [#%s](%s):\n\n~~~ quote\n%s\n\n" % (
|
||||||
|
actor_name,
|
||||||
|
num,
|
||||||
|
url,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
if 'status_id' in changes:
|
if 'status_id' in changes:
|
||||||
status_change = changes.get('status_id')
|
status_change = changes.get('status_id')
|
||||||
content += "Status changed from **%s** to **%s**\n\n" % (status_change[0], status_change[1])
|
content += "Status changed from **%s** to **%s**\n\n" % (
|
||||||
|
status_change[0],
|
||||||
|
status_change[1],
|
||||||
|
)
|
||||||
elif event_type == 'ticketing_milestone':
|
elif event_type == 'ticketing_milestone':
|
||||||
stream = config.ZULIP_TICKETS_STREAM_NAME
|
stream = config.ZULIP_TICKETS_STREAM_NAME
|
||||||
|
|
||||||
|
@ -172,10 +198,17 @@ def handle_event(event: Dict[str, Any]) -> None:
|
||||||
if commit:
|
if commit:
|
||||||
repo_link = raw_props.get('repository_permalink')
|
repo_link = raw_props.get('repository_permalink')
|
||||||
|
|
||||||
url = make_url('projects/%s/repositories/%s/commit/%s' % (project_link, repo_link, commit))
|
url = make_url(
|
||||||
|
'projects/%s/repositories/%s/commit/%s' % (project_link, repo_link, commit)
|
||||||
|
)
|
||||||
|
|
||||||
subject = "%s commented on %s" % (actor_name, commit)
|
subject = "%s commented on %s" % (actor_name, commit)
|
||||||
content = "%s commented on [%s](%s):\n\n~~~ quote\n%s" % (actor_name, commit, url, comment)
|
content = "%s commented on [%s](%s):\n\n~~~ quote\n%s" % (
|
||||||
|
actor_name,
|
||||||
|
commit,
|
||||||
|
url,
|
||||||
|
comment,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Otherwise, this is a Discussion item, and handle it
|
# Otherwise, this is a Discussion item, and handle it
|
||||||
subj = raw_props.get("subject")
|
subj = raw_props.get("subject")
|
||||||
|
@ -199,17 +232,32 @@ def handle_event(event: Dict[str, Any]) -> None:
|
||||||
servers = raw_props.get('servers')
|
servers = raw_props.get('servers')
|
||||||
repo_link = raw_props.get('repository_permalink')
|
repo_link = raw_props.get('repository_permalink')
|
||||||
|
|
||||||
start_ref_url = make_url("projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, start_ref))
|
start_ref_url = make_url(
|
||||||
end_ref_url = make_url("projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, end_ref))
|
"projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, start_ref)
|
||||||
between_url = make_url("projects/%s/repositories/%s/compare/%s...%s" % (
|
)
|
||||||
project_link, repo_link, start_ref, end_ref))
|
end_ref_url = make_url(
|
||||||
|
"projects/%s/repositories/%s/commit/%s" % (project_link, repo_link, end_ref)
|
||||||
|
)
|
||||||
|
between_url = make_url(
|
||||||
|
"projects/%s/repositories/%s/compare/%s...%s"
|
||||||
|
% (project_link, repo_link, start_ref, end_ref)
|
||||||
|
)
|
||||||
|
|
||||||
subject = "Deployment to %s" % (environment,)
|
subject = "Deployment to %s" % (environment,)
|
||||||
|
|
||||||
content = ("%s deployed [%s](%s) [through](%s) [%s](%s) to the **%s** environment." %
|
content = "%s deployed [%s](%s) [through](%s) [%s](%s) to the **%s** environment." % (
|
||||||
(actor_name, start_ref, start_ref_url, between_url, end_ref, end_ref_url, environment))
|
actor_name,
|
||||||
|
start_ref,
|
||||||
|
start_ref_url,
|
||||||
|
between_url,
|
||||||
|
end_ref,
|
||||||
|
end_ref_url,
|
||||||
|
environment,
|
||||||
|
)
|
||||||
if servers is not None:
|
if servers is not None:
|
||||||
content += "\n\nServers deployed to: %s" % (", ".join(["`%s`" % (server,) for server in servers]))
|
content += "\n\nServers deployed to: %s" % (
|
||||||
|
", ".join(["`%s`" % (server,) for server in servers])
|
||||||
|
)
|
||||||
|
|
||||||
elif event_type == 'named_tree':
|
elif event_type == 'named_tree':
|
||||||
# Docs say named_tree type used for new/deleting branches and tags,
|
# Docs say named_tree type used for new/deleting branches and tags,
|
||||||
|
@ -228,10 +276,9 @@ def handle_event(event: Dict[str, Any]) -> None:
|
||||||
if len(subject) > 60:
|
if len(subject) > 60:
|
||||||
subject = subject[:57].rstrip() + '...'
|
subject = subject[:57].rstrip() + '...'
|
||||||
|
|
||||||
res = client.send_message({"type": "stream",
|
res = client.send_message(
|
||||||
"to": stream,
|
{"type": "stream", "to": stream, "subject": subject, "content": content}
|
||||||
"subject": subject,
|
)
|
||||||
"content": content})
|
|
||||||
if res['result'] == 'success':
|
if res['result'] == 'success':
|
||||||
logging.info("Successfully sent Zulip with id: %s" % (res['id'],))
|
logging.info("Successfully sent Zulip with id: %s" % (res['id'],))
|
||||||
else:
|
else:
|
||||||
|
@ -278,6 +325,7 @@ def run_mirror() -> None:
|
||||||
open(config.RESUME_FILE, 'w').write(since.strftime("%s"))
|
open(config.RESUME_FILE, 'w').write(since.strftime("%s"))
|
||||||
logging.info("Shutting down Codebase mirror")
|
logging.info("Shutting down Codebase mirror")
|
||||||
|
|
||||||
|
|
||||||
# void function that checks the permissions of the files this script needs.
|
# void function that checks the permissions of the files this script needs.
|
||||||
def check_permissions() -> None:
|
def check_permissions() -> None:
|
||||||
# check that the log file can be written
|
# check that the log file can be written
|
||||||
|
@ -291,9 +339,12 @@ def check_permissions() -> None:
|
||||||
try:
|
try:
|
||||||
open(config.RESUME_FILE, "a+")
|
open(config.RESUME_FILE, "a+")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
sys.stderr.write("Could not open up the file %s for reading and writing" % (config.RESUME_FILE,))
|
sys.stderr.write(
|
||||||
|
"Could not open up the file %s for reading and writing" % (config.RESUME_FILE,)
|
||||||
|
)
|
||||||
sys.stderr.write(str(e))
|
sys.stderr.write(str(e))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
assert isinstance(config.RESUME_FILE, str), "RESUME_FILE path not given; refusing to continue"
|
assert isinstance(config.RESUME_FILE, str), "RESUME_FILE path not given; refusing to continue"
|
||||||
check_permissions()
|
check_permissions()
|
||||||
|
|
|
@ -29,30 +29,33 @@ client = zulip.Client(
|
||||||
email=config.ZULIP_USER,
|
email=config.ZULIP_USER,
|
||||||
site=config.ZULIP_SITE,
|
site=config.ZULIP_SITE,
|
||||||
api_key=config.ZULIP_API_KEY,
|
api_key=config.ZULIP_API_KEY,
|
||||||
client="ZulipGit/" + VERSION)
|
client="ZulipGit/" + VERSION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def git_repository_name() -> Text:
|
def git_repository_name() -> Text:
|
||||||
output = subprocess.check_output(["git", "rev-parse", "--is-bare-repository"])
|
output = subprocess.check_output(["git", "rev-parse", "--is-bare-repository"])
|
||||||
if output.strip() == "true":
|
if output.strip() == "true":
|
||||||
return os.path.basename(os.getcwd())[:-len(".git")]
|
return os.path.basename(os.getcwd())[: -len(".git")]
|
||||||
else:
|
else:
|
||||||
return os.path.basename(os.path.dirname(os.getcwd()))
|
return os.path.basename(os.path.dirname(os.getcwd()))
|
||||||
|
|
||||||
|
|
||||||
def git_commit_range(oldrev: str, newrev: str) -> str:
|
def git_commit_range(oldrev: str, newrev: str) -> str:
|
||||||
log_cmd = ["git", "log", "--reverse",
|
log_cmd = ["git", "log", "--reverse", "--pretty=%aE %H %s", "%s..%s" % (oldrev, newrev)]
|
||||||
"--pretty=%aE %H %s", "%s..%s" % (oldrev, newrev)]
|
|
||||||
commits = ''
|
commits = ''
|
||||||
for ln in subprocess.check_output(log_cmd, universal_newlines=True).splitlines():
|
for ln in subprocess.check_output(log_cmd, universal_newlines=True).splitlines():
|
||||||
author_email, commit_id, subject = ln.split(None, 2)
|
author_email, commit_id, subject = ln.split(None, 2)
|
||||||
if hasattr(config, "format_commit_message"):
|
if hasattr(config, "format_commit_message"):
|
||||||
commits += config.format_commit_message(author_email, subject, commit_id)
|
commits += config.format_commit_message(author_email, subject, commit_id)
|
||||||
else:
|
else:
|
||||||
commits += '!avatar(%s) %s\n' % (author_email, subject)
|
commits += '!avatar(%s) %s\n' % (author_email, subject)
|
||||||
return commits
|
return commits
|
||||||
|
|
||||||
|
|
||||||
def send_bot_message(oldrev: str, newrev: str, refname: str) -> None:
|
def send_bot_message(oldrev: str, newrev: str, refname: str) -> None:
|
||||||
repo_name = git_repository_name()
|
repo_name = git_repository_name()
|
||||||
branch = refname.replace('refs/heads/', '')
|
branch = refname.replace('refs/heads/', '')
|
||||||
destination = config.commit_notice_destination(repo_name, branch, newrev)
|
destination = config.commit_notice_destination(repo_name, branch, newrev)
|
||||||
if destination is None:
|
if destination is None:
|
||||||
# Don't forward the notice anywhere
|
# Don't forward the notice anywhere
|
||||||
|
@ -69,7 +72,7 @@ def send_bot_message(oldrev: str, newrev: str, refname: str) -> None:
|
||||||
added = ''
|
added = ''
|
||||||
removed = ''
|
removed = ''
|
||||||
else:
|
else:
|
||||||
added = git_commit_range(oldrev, newrev)
|
added = git_commit_range(oldrev, newrev)
|
||||||
removed = git_commit_range(newrev, oldrev)
|
removed = git_commit_range(newrev, oldrev)
|
||||||
|
|
||||||
if oldrev == '0000000000000000000000000000000000000000':
|
if oldrev == '0000000000000000000000000000000000000000':
|
||||||
|
@ -95,6 +98,7 @@ def send_bot_message(oldrev: str, newrev: str, refname: str) -> None:
|
||||||
}
|
}
|
||||||
client.send_message(message_data)
|
client.send_message(message_data)
|
||||||
|
|
||||||
|
|
||||||
for ln in sys.stdin:
|
for ln in sys.stdin:
|
||||||
oldrev, newrev, refname = ln.strip().split()
|
oldrev, newrev, refname = ln.strip().split()
|
||||||
send_bot_message(oldrev, newrev, refname)
|
send_bot_message(oldrev, newrev, refname)
|
||||||
|
|
|
@ -25,12 +25,12 @@ ZULIP_API_KEY = "0123456789abcdef0123456789abcdef"
|
||||||
# And similarly for branch "test-post-receive" (for use when testing).
|
# And similarly for branch "test-post-receive" (for use when testing).
|
||||||
def commit_notice_destination(repo: Text, branch: Text, commit: Text) -> Optional[Dict[Text, Text]]:
|
def commit_notice_destination(repo: Text, branch: Text, commit: Text) -> Optional[Dict[Text, Text]]:
|
||||||
if branch in ["master", "test-post-receive"]:
|
if branch in ["master", "test-post-receive"]:
|
||||||
return dict(stream = STREAM_NAME,
|
return dict(stream=STREAM_NAME, subject="%s" % (branch,))
|
||||||
subject = "%s" % (branch,))
|
|
||||||
|
|
||||||
# Return None for cases where you don't want a notice sent
|
# Return None for cases where you don't want a notice sent
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Modify this function to change how commits are displayed; the most
|
# Modify this function to change how commits are displayed; the most
|
||||||
# common customization is to include a link to the commit in your
|
# common customization is to include a link to the commit in your
|
||||||
# graphical repository viewer, e.g.
|
# graphical repository viewer, e.g.
|
||||||
|
@ -39,6 +39,7 @@ def commit_notice_destination(repo: Text, branch: Text, commit: Text) -> Optiona
|
||||||
def format_commit_message(author: Text, subject: Text, commit_id: Text) -> Text:
|
def format_commit_message(author: Text, subject: Text, commit_id: Text) -> Text:
|
||||||
return '!avatar(%s) %s\n' % (author, subject)
|
return '!avatar(%s) %s\n' % (author, subject)
|
||||||
|
|
||||||
|
|
||||||
## If properly installed, the Zulip API should be in your import
|
## If properly installed, the Zulip API should be in your import
|
||||||
## path, but if not, set a custom path below
|
## path, but if not, set a custom path below
|
||||||
ZULIP_API_PATH: Optional[str] = None
|
ZULIP_API_PATH: Optional[str] = None
|
||||||
|
|
|
@ -7,7 +7,10 @@ from oauth2client.file import Storage
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import argparse
|
import argparse
|
||||||
flags = argparse.ArgumentParser(parents=[tools.argparser]).parse_args() # type: Optional[argparse.Namespace]
|
|
||||||
|
flags = argparse.ArgumentParser(
|
||||||
|
parents=[tools.argparser]
|
||||||
|
).parse_args() # type: Optional[argparse.Namespace]
|
||||||
except ImportError:
|
except ImportError:
|
||||||
flags = None
|
flags = None
|
||||||
|
|
||||||
|
@ -22,6 +25,7 @@ CLIENT_SECRET_FILE = 'client_secret.json'
|
||||||
APPLICATION_NAME = 'Zulip Calendar Bot'
|
APPLICATION_NAME = 'Zulip Calendar Bot'
|
||||||
HOME_DIR = os.path.expanduser('~')
|
HOME_DIR = os.path.expanduser('~')
|
||||||
|
|
||||||
|
|
||||||
def get_credentials() -> client.Credentials:
|
def get_credentials() -> client.Credentials:
|
||||||
"""Gets valid user credentials from storage.
|
"""Gets valid user credentials from storage.
|
||||||
|
|
||||||
|
@ -32,8 +36,7 @@ def get_credentials() -> client.Credentials:
|
||||||
Credentials, the obtained credential.
|
Credentials, the obtained credential.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
credential_path = os.path.join(HOME_DIR,
|
credential_path = os.path.join(HOME_DIR, 'google-credentials.json')
|
||||||
'google-credentials.json')
|
|
||||||
|
|
||||||
store = Storage(credential_path)
|
store = Storage(credential_path)
|
||||||
credentials = store.get()
|
credentials = store.get()
|
||||||
|
@ -49,4 +52,5 @@ def get_credentials() -> client.Credentials:
|
||||||
credentials = tools.run(flow, store)
|
credentials = tools.run(flow, store)
|
||||||
print('Storing credentials to ' + credential_path)
|
print('Storing credentials to ' + credential_path)
|
||||||
|
|
||||||
|
|
||||||
get_credentials()
|
get_credentials()
|
||||||
|
|
|
@ -38,7 +38,9 @@ sent = set() # type: Set[Tuple[int, datetime.datetime]]
|
||||||
|
|
||||||
sys.path.append(os.path.dirname(__file__))
|
sys.path.append(os.path.dirname(__file__))
|
||||||
|
|
||||||
parser = zulip.add_default_arguments(argparse.ArgumentParser(r"""
|
parser = zulip.add_default_arguments(
|
||||||
|
argparse.ArgumentParser(
|
||||||
|
r"""
|
||||||
|
|
||||||
google-calendar --calendar calendarID@example.calendar.google.com
|
google-calendar --calendar calendarID@example.calendar.google.com
|
||||||
|
|
||||||
|
@ -53,23 +55,29 @@ google-calendar --calendar calendarID@example.calendar.google.com
|
||||||
revealed to local users through the command line.
|
revealed to local users through the command line.
|
||||||
|
|
||||||
Depends on: google-api-python-client
|
Depends on: google-api-python-client
|
||||||
"""))
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
parser.add_argument('--interval',
|
parser.add_argument(
|
||||||
dest='interval',
|
'--interval',
|
||||||
default=30,
|
dest='interval',
|
||||||
type=int,
|
default=30,
|
||||||
action='store',
|
type=int,
|
||||||
help='Minutes before event for reminder [default: 30]',
|
action='store',
|
||||||
metavar='MINUTES')
|
help='Minutes before event for reminder [default: 30]',
|
||||||
|
metavar='MINUTES',
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument('--calendar',
|
parser.add_argument(
|
||||||
dest = 'calendarID',
|
'--calendar',
|
||||||
default = 'primary',
|
dest='calendarID',
|
||||||
type = str,
|
default='primary',
|
||||||
action = 'store',
|
type=str,
|
||||||
help = 'Calendar ID for the calendar you want to receive reminders from.')
|
action='store',
|
||||||
|
help='Calendar ID for the calendar you want to receive reminders from.',
|
||||||
|
)
|
||||||
|
|
||||||
options = parser.parse_args()
|
options = parser.parse_args()
|
||||||
|
|
||||||
|
@ -78,6 +86,7 @@ if not (options.zulip_email):
|
||||||
|
|
||||||
zulip_client = zulip.init_from_options(options)
|
zulip_client = zulip.init_from_options(options)
|
||||||
|
|
||||||
|
|
||||||
def get_credentials() -> client.Credentials:
|
def get_credentials() -> client.Credentials:
|
||||||
"""Gets valid user credentials from storage.
|
"""Gets valid user credentials from storage.
|
||||||
|
|
||||||
|
@ -89,8 +98,7 @@ def get_credentials() -> client.Credentials:
|
||||||
Credentials, the obtained credential.
|
Credentials, the obtained credential.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
credential_path = os.path.join(HOME_DIR,
|
credential_path = os.path.join(HOME_DIR, 'google-credentials.json')
|
||||||
'google-credentials.json')
|
|
||||||
|
|
||||||
store = Storage(credential_path)
|
store = Storage(credential_path)
|
||||||
credentials = store.get()
|
credentials = store.get()
|
||||||
|
@ -110,8 +118,17 @@ def populate_events() -> Optional[None]:
|
||||||
service = discovery.build('calendar', 'v3', http=creds)
|
service = discovery.build('calendar', 'v3', http=creds)
|
||||||
|
|
||||||
now = datetime.datetime.now(pytz.utc).isoformat()
|
now = datetime.datetime.now(pytz.utc).isoformat()
|
||||||
feed = service.events().list(calendarId=options.calendarID, timeMin=now, maxResults=5,
|
feed = (
|
||||||
singleEvents=True, orderBy='startTime').execute()
|
service.events()
|
||||||
|
.list(
|
||||||
|
calendarId=options.calendarID,
|
||||||
|
timeMin=now,
|
||||||
|
maxResults=5,
|
||||||
|
singleEvents=True,
|
||||||
|
orderBy='startTime',
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
|
||||||
events = []
|
events = []
|
||||||
for event in feed["items"]:
|
for event in feed["items"]:
|
||||||
|
@ -172,14 +189,13 @@ def send_reminders() -> Optional[None]:
|
||||||
else:
|
else:
|
||||||
message = 'Reminder:\n\n' + '\n'.join('* ' + m for m in messages)
|
message = 'Reminder:\n\n' + '\n'.join('* ' + m for m in messages)
|
||||||
|
|
||||||
zulip_client.send_message(dict(
|
zulip_client.send_message(
|
||||||
type = 'private',
|
dict(type='private', to=options.zulip_email, sender=options.zulip_email, content=message)
|
||||||
to = options.zulip_email,
|
)
|
||||||
sender = options.zulip_email,
|
|
||||||
content = message))
|
|
||||||
|
|
||||||
sent.update(keys)
|
sent.update(keys)
|
||||||
|
|
||||||
|
|
||||||
# Loop forever
|
# Loop forever
|
||||||
for i in itertools.count():
|
for i in itertools.count():
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -15,7 +15,10 @@ import zulip
|
||||||
|
|
||||||
VERSION = "0.9"
|
VERSION = "0.9"
|
||||||
|
|
||||||
def format_summary_line(web_url: str, user: str, base: int, tip: int, branch: str, node: Text) -> Text:
|
|
||||||
|
def format_summary_line(
|
||||||
|
web_url: str, user: str, base: int, tip: int, branch: str, node: Text
|
||||||
|
) -> Text:
|
||||||
"""
|
"""
|
||||||
Format the first line of the message, which contains summary
|
Format the first line of the message, which contains summary
|
||||||
information about the changeset and links to the changelog if a
|
information about the changeset and links to the changelog if a
|
||||||
|
@ -29,16 +32,18 @@ def format_summary_line(web_url: str, user: str, base: int, tip: int, branch: st
|
||||||
if web_url:
|
if web_url:
|
||||||
shortlog_base_url = web_url.rstrip("/") + "/shortlog/"
|
shortlog_base_url = web_url.rstrip("/") + "/shortlog/"
|
||||||
summary_url = "{shortlog}{tip}?revcount={revcount}".format(
|
summary_url = "{shortlog}{tip}?revcount={revcount}".format(
|
||||||
shortlog=shortlog_base_url, tip=tip - 1, revcount=revcount)
|
shortlog=shortlog_base_url, tip=tip - 1, revcount=revcount
|
||||||
|
)
|
||||||
formatted_commit_count = "[{revcount} commit{s}]({url})".format(
|
formatted_commit_count = "[{revcount} commit{s}]({url})".format(
|
||||||
revcount=revcount, s=plural, url=summary_url)
|
revcount=revcount, s=plural, url=summary_url
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
formatted_commit_count = "{revcount} commit{s}".format(
|
formatted_commit_count = "{revcount} commit{s}".format(revcount=revcount, s=plural)
|
||||||
revcount=revcount, s=plural)
|
|
||||||
|
|
||||||
return "**{user}** pushed {commits} to **{branch}** (`{tip}:{node}`):\n\n".format(
|
return "**{user}** pushed {commits} to **{branch}** (`{tip}:{node}`):\n\n".format(
|
||||||
user=user, commits=formatted_commit_count, branch=branch, tip=tip,
|
user=user, commits=formatted_commit_count, branch=branch, tip=tip, node=node[:12]
|
||||||
node=node[:12])
|
)
|
||||||
|
|
||||||
|
|
||||||
def format_commit_lines(web_url: str, repo: repo, base: int, tip: int) -> str:
|
def format_commit_lines(web_url: str, repo: repo, base: int, tip: int) -> str:
|
||||||
"""
|
"""
|
||||||
|
@ -56,8 +61,7 @@ def format_commit_lines(web_url: str, repo: repo, base: int, tip: int) -> str:
|
||||||
|
|
||||||
if web_url:
|
if web_url:
|
||||||
summary_url = rev_base_url + str(rev_ctx)
|
summary_url = rev_base_url + str(rev_ctx)
|
||||||
summary = "* [{summary}]({url})".format(
|
summary = "* [{summary}]({url})".format(summary=one_liner, url=summary_url)
|
||||||
summary=one_liner, url=summary_url)
|
|
||||||
else:
|
else:
|
||||||
summary = "* {summary}".format(summary=one_liner)
|
summary = "* {summary}".format(summary=one_liner)
|
||||||
|
|
||||||
|
@ -65,14 +69,17 @@ def format_commit_lines(web_url: str, repo: repo, base: int, tip: int) -> str:
|
||||||
|
|
||||||
return "\n".join(summary for summary in commit_summaries)
|
return "\n".join(summary for summary in commit_summaries)
|
||||||
|
|
||||||
def send_zulip(email: str, api_key: str, site: str, stream: str, subject: str, content: Text) -> None:
|
|
||||||
|
def send_zulip(
|
||||||
|
email: str, api_key: str, site: str, stream: str, subject: str, content: Text
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Send a message to Zulip using the provided credentials, which should be for
|
Send a message to Zulip using the provided credentials, which should be for
|
||||||
a bot in most cases.
|
a bot in most cases.
|
||||||
"""
|
"""
|
||||||
client = zulip.Client(email=email, api_key=api_key,
|
client = zulip.Client(
|
||||||
site=site,
|
email=email, api_key=api_key, site=site, client="ZulipMercurial/" + VERSION
|
||||||
client="ZulipMercurial/" + VERSION)
|
)
|
||||||
|
|
||||||
message_data = {
|
message_data = {
|
||||||
"type": "stream",
|
"type": "stream",
|
||||||
|
@ -83,6 +90,7 @@ def send_zulip(email: str, api_key: str, site: str, stream: str, subject: str, c
|
||||||
|
|
||||||
client.send_message(message_data)
|
client.send_message(message_data)
|
||||||
|
|
||||||
|
|
||||||
def get_config(ui: ui, item: str) -> str:
|
def get_config(ui: ui, item: str) -> str:
|
||||||
try:
|
try:
|
||||||
# config returns configuration value.
|
# config returns configuration value.
|
||||||
|
@ -91,6 +99,7 @@ def get_config(ui: ui, item: str) -> str:
|
||||||
ui.warn("Zulip: Could not find required item {} in hg config.".format(item))
|
ui.warn("Zulip: Could not find required item {} in hg config.".format(item))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def hook(ui: ui, repo: repo, **kwargs: Text) -> None:
|
def hook(ui: ui, repo: repo, **kwargs: Text) -> None:
|
||||||
"""
|
"""
|
||||||
Invoked by configuring a [hook] entry in .hg/hgrc.
|
Invoked by configuring a [hook] entry in .hg/hgrc.
|
||||||
|
|
|
@ -14,6 +14,7 @@ def die(signal: int, frame: FrameType) -> None:
|
||||||
"""We actually want to exit, so run os._exit (so as not to be caught and restarted)"""
|
"""We actually want to exit, so run os._exit (so as not to be caught and restarted)"""
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, die)
|
signal.signal(signal.SIGINT, die)
|
||||||
|
|
||||||
args = [os.path.join(os.path.dirname(sys.argv[0]), "jabber_mirror_backend.py")]
|
args = [os.path.join(os.path.dirname(sys.argv[0]), "jabber_mirror_backend.py")]
|
||||||
|
|
|
@ -52,18 +52,22 @@ from zulip import Client
|
||||||
|
|
||||||
__version__ = "1.1"
|
__version__ = "1.1"
|
||||||
|
|
||||||
|
|
||||||
def room_to_stream(room: str) -> str:
|
def room_to_stream(room: str) -> str:
|
||||||
return room + "/xmpp"
|
return room + "/xmpp"
|
||||||
|
|
||||||
|
|
||||||
def stream_to_room(stream: str) -> str:
|
def stream_to_room(stream: str) -> str:
|
||||||
return stream.lower().rpartition("/xmpp")[0]
|
return stream.lower().rpartition("/xmpp")[0]
|
||||||
|
|
||||||
|
|
||||||
def jid_to_zulip(jid: JID) -> str:
|
def jid_to_zulip(jid: JID) -> str:
|
||||||
suffix = ''
|
suffix = ''
|
||||||
if not jid.username.endswith("-bot"):
|
if not jid.username.endswith("-bot"):
|
||||||
suffix = options.zulip_email_suffix
|
suffix = options.zulip_email_suffix
|
||||||
return "%s%s@%s" % (jid.username, suffix, options.zulip_domain)
|
return "%s%s@%s" % (jid.username, suffix, options.zulip_domain)
|
||||||
|
|
||||||
|
|
||||||
def zulip_to_jid(email: str, jabber_domain: str) -> JID:
|
def zulip_to_jid(email: str, jabber_domain: str) -> JID:
|
||||||
jid = JID(email, domain=jabber_domain)
|
jid = JID(email, domain=jabber_domain)
|
||||||
if (
|
if (
|
||||||
|
@ -74,6 +78,7 @@ def zulip_to_jid(email: str, jabber_domain: str) -> JID:
|
||||||
jid.username = jid.username.rpartition(options.zulip_email_suffix)[0]
|
jid.username = jid.username.rpartition(options.zulip_email_suffix)[0]
|
||||||
return jid
|
return jid
|
||||||
|
|
||||||
|
|
||||||
class JabberToZulipBot(ClientXMPP):
|
class JabberToZulipBot(ClientXMPP):
|
||||||
def __init__(self, jid: JID, password: str, rooms: List[str]) -> None:
|
def __init__(self, jid: JID, password: str, rooms: List[str]) -> None:
|
||||||
if jid.resource:
|
if jid.resource:
|
||||||
|
@ -153,10 +158,10 @@ class JabberToZulipBot(ClientXMPP):
|
||||||
recipient = jid_to_zulip(msg["to"])
|
recipient = jid_to_zulip(msg["to"])
|
||||||
|
|
||||||
zulip_message = dict(
|
zulip_message = dict(
|
||||||
sender = sender,
|
sender=sender,
|
||||||
type = "private",
|
type="private",
|
||||||
to = recipient,
|
to=recipient,
|
||||||
content = msg["body"],
|
content=msg["body"],
|
||||||
)
|
)
|
||||||
ret = self.zulipToJabber.client.send_message(zulip_message)
|
ret = self.zulipToJabber.client.send_message(zulip_message)
|
||||||
if ret.get("result") != "success":
|
if ret.get("result") != "success":
|
||||||
|
@ -178,12 +183,12 @@ class JabberToZulipBot(ClientXMPP):
|
||||||
jid = self.nickname_to_jid(msg.get_mucroom(), sender_nick)
|
jid = self.nickname_to_jid(msg.get_mucroom(), sender_nick)
|
||||||
sender = jid_to_zulip(jid)
|
sender = jid_to_zulip(jid)
|
||||||
zulip_message = dict(
|
zulip_message = dict(
|
||||||
forged = "yes",
|
forged="yes",
|
||||||
sender = sender,
|
sender=sender,
|
||||||
type = "stream",
|
type="stream",
|
||||||
subject = subject,
|
subject=subject,
|
||||||
to = stream,
|
to=stream,
|
||||||
content = msg["body"],
|
content=msg["body"],
|
||||||
)
|
)
|
||||||
ret = self.zulipToJabber.client.send_message(zulip_message)
|
ret = self.zulipToJabber.client.send_message(zulip_message)
|
||||||
if ret.get("result") != "success":
|
if ret.get("result") != "success":
|
||||||
|
@ -191,11 +196,12 @@ class JabberToZulipBot(ClientXMPP):
|
||||||
|
|
||||||
def nickname_to_jid(self, room: str, nick: str) -> JID:
|
def nickname_to_jid(self, room: str, nick: str) -> JID:
|
||||||
jid = self.plugin['xep_0045'].getJidProperty(room, nick, "jid")
|
jid = self.plugin['xep_0045'].getJidProperty(room, nick, "jid")
|
||||||
if (jid is None or jid == ''):
|
if jid is None or jid == '':
|
||||||
return JID(local=nick.replace(' ', ''), domain=self.boundjid.domain)
|
return JID(local=nick.replace(' ', ''), domain=self.boundjid.domain)
|
||||||
else:
|
else:
|
||||||
return jid
|
return jid
|
||||||
|
|
||||||
|
|
||||||
class ZulipToJabberBot:
|
class ZulipToJabberBot:
|
||||||
def __init__(self, zulip_client: Client) -> None:
|
def __init__(self, zulip_client: Client) -> None:
|
||||||
self.client = zulip_client
|
self.client = zulip_client
|
||||||
|
@ -221,7 +227,7 @@ class ZulipToJabberBot:
|
||||||
self.process_subscription(event)
|
self.process_subscription(event)
|
||||||
|
|
||||||
def stream_message(self, msg: Dict[str, str]) -> None:
|
def stream_message(self, msg: Dict[str, str]) -> None:
|
||||||
assert(self.jabber is not None)
|
assert self.jabber is not None
|
||||||
stream = msg['display_recipient']
|
stream = msg['display_recipient']
|
||||||
if not stream.endswith("/xmpp"):
|
if not stream.endswith("/xmpp"):
|
||||||
return
|
return
|
||||||
|
@ -229,14 +235,13 @@ class ZulipToJabberBot:
|
||||||
room = stream_to_room(stream)
|
room = stream_to_room(stream)
|
||||||
jabber_recipient = JID(local=room, domain=options.conference_domain)
|
jabber_recipient = JID(local=room, domain=options.conference_domain)
|
||||||
outgoing = self.jabber.make_message(
|
outgoing = self.jabber.make_message(
|
||||||
mto = jabber_recipient,
|
mto=jabber_recipient, mbody=msg['content'], mtype='groupchat'
|
||||||
mbody = msg['content'],
|
)
|
||||||
mtype = 'groupchat')
|
|
||||||
outgoing['thread'] = '\u1FFFE'
|
outgoing['thread'] = '\u1FFFE'
|
||||||
outgoing.send()
|
outgoing.send()
|
||||||
|
|
||||||
def private_message(self, msg: Dict[str, Any]) -> None:
|
def private_message(self, msg: Dict[str, Any]) -> None:
|
||||||
assert(self.jabber is not None)
|
assert self.jabber is not None
|
||||||
for recipient in msg['display_recipient']:
|
for recipient in msg['display_recipient']:
|
||||||
if recipient["email"] == self.client.email:
|
if recipient["email"] == self.client.email:
|
||||||
continue
|
continue
|
||||||
|
@ -245,14 +250,13 @@ class ZulipToJabberBot:
|
||||||
recip_email = recipient['email']
|
recip_email = recipient['email']
|
||||||
jabber_recipient = zulip_to_jid(recip_email, self.jabber.boundjid.domain)
|
jabber_recipient = zulip_to_jid(recip_email, self.jabber.boundjid.domain)
|
||||||
outgoing = self.jabber.make_message(
|
outgoing = self.jabber.make_message(
|
||||||
mto = jabber_recipient,
|
mto=jabber_recipient, mbody=msg['content'], mtype='chat'
|
||||||
mbody = msg['content'],
|
)
|
||||||
mtype = 'chat')
|
|
||||||
outgoing['thread'] = '\u1FFFE'
|
outgoing['thread'] = '\u1FFFE'
|
||||||
outgoing.send()
|
outgoing.send()
|
||||||
|
|
||||||
def process_subscription(self, event: Dict[str, Any]) -> None:
|
def process_subscription(self, event: Dict[str, Any]) -> None:
|
||||||
assert(self.jabber is not None)
|
assert self.jabber is not None
|
||||||
if event['op'] == 'add':
|
if event['op'] == 'add':
|
||||||
streams = [s['name'].lower() for s in event['subscriptions']]
|
streams = [s['name'].lower() for s in event['subscriptions']]
|
||||||
streams = [s for s in streams if s.endswith("/xmpp")]
|
streams = [s for s in streams if s.endswith("/xmpp")]
|
||||||
|
@ -264,6 +268,7 @@ class ZulipToJabberBot:
|
||||||
for stream in streams:
|
for stream in streams:
|
||||||
self.jabber.leave_muc(stream_to_room(stream))
|
self.jabber.leave_muc(stream_to_room(stream))
|
||||||
|
|
||||||
|
|
||||||
def get_rooms(zulipToJabber: ZulipToJabberBot) -> List[str]:
|
def get_rooms(zulipToJabber: ZulipToJabberBot) -> List[str]:
|
||||||
def get_stream_infos(key: str, method: Callable[[], Dict[str, Any]]) -> Any:
|
def get_stream_infos(key: str, method: Callable[[], Dict[str, Any]]) -> Any:
|
||||||
ret = method()
|
ret = method()
|
||||||
|
@ -284,17 +289,21 @@ def get_rooms(zulipToJabber: ZulipToJabberBot) -> List[str]:
|
||||||
rooms.append(stream_to_room(stream))
|
rooms.append(stream_to_room(stream))
|
||||||
return rooms
|
return rooms
|
||||||
|
|
||||||
|
|
||||||
def config_error(msg: str) -> None:
|
def config_error(msg: str) -> None:
|
||||||
sys.stderr.write("%s\n" % (msg,))
|
sys.stderr.write("%s\n" % (msg,))
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
parser = optparse.OptionParser(
|
parser = optparse.OptionParser(
|
||||||
epilog='''Most general and Jabber configuration options may also be specified in the
|
epilog='''Most general and Jabber configuration options may also be specified in the
|
||||||
zulip configuration file under the jabber_mirror section (exceptions are noted
|
zulip configuration file under the jabber_mirror section (exceptions are noted
|
||||||
in their help sections). Keys have the same name as options with hyphens
|
in their help sections). Keys have the same name as options with hyphens
|
||||||
replaced with underscores. Zulip configuration options go in the api section,
|
replaced with underscores. Zulip configuration options go in the api section,
|
||||||
as normal.'''.replace("\n", " ")
|
as normal.'''.replace(
|
||||||
|
"\n", " "
|
||||||
|
)
|
||||||
)
|
)
|
||||||
parser.add_option(
|
parser.add_option(
|
||||||
'--mode',
|
'--mode',
|
||||||
|
@ -305,7 +314,10 @@ as normal.'''.replace("\n", " ")
|
||||||
all messages they send on Zulip to Jabber and all private Jabber messages to
|
all messages they send on Zulip to Jabber and all private Jabber messages to
|
||||||
Zulip. In "public" mode, the mirror uses the credentials for a dedicated mirror
|
Zulip. In "public" mode, the mirror uses the credentials for a dedicated mirror
|
||||||
user and mirrors messages sent to Jabber rooms to Zulip. Defaults to
|
user and mirrors messages sent to Jabber rooms to Zulip. Defaults to
|
||||||
"personal"'''.replace("\n", " "))
|
"personal"'''.replace(
|
||||||
|
"\n", " "
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_option(
|
parser.add_option(
|
||||||
'--zulip-email-suffix',
|
'--zulip-email-suffix',
|
||||||
default=None,
|
default=None,
|
||||||
|
@ -315,13 +327,19 @@ from JIDs and nicks before sending requests to the Zulip server, and remove the
|
||||||
suffix before sending requests to the Jabber server. For example, specifying
|
suffix before sending requests to the Jabber server. For example, specifying
|
||||||
"+foo" will cause messages that are sent to the "bar" room by nickname "qux" to
|
"+foo" will cause messages that are sent to the "bar" room by nickname "qux" to
|
||||||
be mirrored to the "bar/xmpp" stream in Zulip by user "qux+foo@example.com". This
|
be mirrored to the "bar/xmpp" stream in Zulip by user "qux+foo@example.com". This
|
||||||
option does not affect login credentials.'''.replace("\n", " "))
|
option does not affect login credentials.'''.replace(
|
||||||
parser.add_option('-d', '--debug',
|
"\n", " "
|
||||||
help='set logging to DEBUG. Can not be set via config file.',
|
),
|
||||||
action='store_const',
|
)
|
||||||
dest='log_level',
|
parser.add_option(
|
||||||
const=logging.DEBUG,
|
'-d',
|
||||||
default=logging.INFO)
|
'--debug',
|
||||||
|
help='set logging to DEBUG. Can not be set via config file.',
|
||||||
|
action='store_const',
|
||||||
|
dest='log_level',
|
||||||
|
const=logging.DEBUG,
|
||||||
|
default=logging.INFO,
|
||||||
|
)
|
||||||
|
|
||||||
jabber_group = optparse.OptionGroup(parser, "Jabber configuration")
|
jabber_group = optparse.OptionGroup(parser, "Jabber configuration")
|
||||||
jabber_group.add_option(
|
jabber_group.add_option(
|
||||||
|
@ -329,39 +347,42 @@ option does not affect login credentials.'''.replace("\n", " "))
|
||||||
default=None,
|
default=None,
|
||||||
action='store',
|
action='store',
|
||||||
help="Your Jabber JID. If a resource is specified, "
|
help="Your Jabber JID. If a resource is specified, "
|
||||||
"it will be used as the nickname when joining MUCs. "
|
"it will be used as the nickname when joining MUCs. "
|
||||||
"Specifying the nickname is mostly useful if you want "
|
"Specifying the nickname is mostly useful if you want "
|
||||||
"to run the public mirror from a regular user instead of "
|
"to run the public mirror from a regular user instead of "
|
||||||
"from a dedicated account.")
|
"from a dedicated account.",
|
||||||
jabber_group.add_option('--jabber-password',
|
)
|
||||||
default=None,
|
jabber_group.add_option(
|
||||||
action='store',
|
'--jabber-password', default=None, action='store', help="Your Jabber password"
|
||||||
help="Your Jabber password")
|
)
|
||||||
jabber_group.add_option('--conference-domain',
|
jabber_group.add_option(
|
||||||
default=None,
|
'--conference-domain',
|
||||||
action='store',
|
default=None,
|
||||||
help="Your Jabber conference domain (E.g. conference.jabber.example.com). "
|
action='store',
|
||||||
"If not specifed, \"conference.\" will be prepended to your JID's domain.")
|
help="Your Jabber conference domain (E.g. conference.jabber.example.com). "
|
||||||
jabber_group.add_option('--no-use-tls',
|
"If not specifed, \"conference.\" will be prepended to your JID's domain.",
|
||||||
default=None,
|
)
|
||||||
action='store_true')
|
jabber_group.add_option('--no-use-tls', default=None, action='store_true')
|
||||||
jabber_group.add_option('--jabber-server-address',
|
jabber_group.add_option(
|
||||||
default=None,
|
'--jabber-server-address',
|
||||||
action='store',
|
default=None,
|
||||||
help="The hostname of your Jabber server. This is only needed if "
|
action='store',
|
||||||
"your server is missing SRV records")
|
help="The hostname of your Jabber server. This is only needed if "
|
||||||
jabber_group.add_option('--jabber-server-port',
|
"your server is missing SRV records",
|
||||||
default='5222',
|
)
|
||||||
action='store',
|
jabber_group.add_option(
|
||||||
help="The port of your Jabber server. This is only needed if "
|
'--jabber-server-port',
|
||||||
"your server is missing SRV records")
|
default='5222',
|
||||||
|
action='store',
|
||||||
|
help="The port of your Jabber server. This is only needed if "
|
||||||
|
"your server is missing SRV records",
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_option_group(jabber_group)
|
parser.add_option_group(jabber_group)
|
||||||
parser.add_option_group(zulip.generate_option_group(parser, "zulip-"))
|
parser.add_option_group(zulip.generate_option_group(parser, "zulip-"))
|
||||||
(options, args) = parser.parse_args()
|
(options, args) = parser.parse_args()
|
||||||
|
|
||||||
logging.basicConfig(level=options.log_level,
|
logging.basicConfig(level=options.log_level, format='%(levelname)-8s %(message)s')
|
||||||
format='%(levelname)-8s %(message)s')
|
|
||||||
|
|
||||||
if options.zulip_config_file is None:
|
if options.zulip_config_file is None:
|
||||||
default_config_file = zulip.get_default_config_filename()
|
default_config_file = zulip.get_default_config_filename()
|
||||||
|
@ -378,12 +399,16 @@ option does not affect login credentials.'''.replace("\n", " "))
|
||||||
config.readfp(f, config_file)
|
config.readfp(f, config_file)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
for option in ("jid", "jabber_password", "conference_domain", "mode", "zulip_email_suffix",
|
for option in (
|
||||||
"jabber_server_address", "jabber_server_port"):
|
"jid",
|
||||||
if (
|
"jabber_password",
|
||||||
getattr(options, option) is None
|
"conference_domain",
|
||||||
and config.has_option("jabber_mirror", option)
|
"mode",
|
||||||
):
|
"zulip_email_suffix",
|
||||||
|
"jabber_server_address",
|
||||||
|
"jabber_server_port",
|
||||||
|
):
|
||||||
|
if getattr(options, option) is None and config.has_option("jabber_mirror", option):
|
||||||
setattr(options, option, config.get("jabber_mirror", option))
|
setattr(options, option, config.get("jabber_mirror", option))
|
||||||
|
|
||||||
for option in ("no_use_tls",):
|
for option in ("no_use_tls",):
|
||||||
|
@ -403,10 +428,14 @@ option does not affect login credentials.'''.replace("\n", " "))
|
||||||
config_error("Bad value for --mode: must be one of 'public' or 'personal'")
|
config_error("Bad value for --mode: must be one of 'public' or 'personal'")
|
||||||
|
|
||||||
if None in (options.jid, options.jabber_password):
|
if None in (options.jid, options.jabber_password):
|
||||||
config_error("You must specify your Jabber JID and Jabber password either "
|
config_error(
|
||||||
"in the Zulip configuration file or on the commandline")
|
"You must specify your Jabber JID and Jabber password either "
|
||||||
|
"in the Zulip configuration file or on the commandline"
|
||||||
|
)
|
||||||
|
|
||||||
zulipToJabber = ZulipToJabberBot(zulip.init_from_options(options, "JabberMirror/" + __version__))
|
zulipToJabber = ZulipToJabberBot(
|
||||||
|
zulip.init_from_options(options, "JabberMirror/" + __version__)
|
||||||
|
)
|
||||||
# This won't work for open realms that don't have a consistent domain
|
# This won't work for open realms that don't have a consistent domain
|
||||||
options.zulip_domain = zulipToJabber.client.email.partition('@')[-1]
|
options.zulip_domain = zulipToJabber.client.email.partition('@')[-1]
|
||||||
|
|
||||||
|
@ -438,8 +467,9 @@ option does not affect login credentials.'''.replace("\n", " "))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logging.info("Connecting to Zulip.")
|
logging.info("Connecting to Zulip.")
|
||||||
zulipToJabber.client.call_on_each_event(zulipToJabber.process_event,
|
zulipToJabber.client.call_on_each_event(
|
||||||
event_types=event_types)
|
zulipToJabber.process_event, event_types=event_types
|
||||||
|
)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
logging.exception("Exception in main loop")
|
logging.exception("Exception in main loop")
|
||||||
xmpp.abort()
|
xmpp.abort()
|
||||||
|
|
|
@ -14,10 +14,12 @@ import traceback
|
||||||
sys.path.append("/home/zulip/deployments/current")
|
sys.path.append("/home/zulip/deployments/current")
|
||||||
try:
|
try:
|
||||||
from scripts.lib.setup_path import setup_path
|
from scripts.lib.setup_path import setup_path
|
||||||
|
|
||||||
setup_path()
|
setup_path()
|
||||||
except ImportError:
|
except ImportError:
|
||||||
try:
|
try:
|
||||||
import scripts.lib.setup_path_on_import
|
import scripts.lib.setup_path_on_import
|
||||||
|
|
||||||
scripts.lib.setup_path_on_import # Suppress unused import warning
|
scripts.lib.setup_path_on_import # Suppress unused import warning
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
@ -31,6 +33,7 @@ import zulip
|
||||||
|
|
||||||
temp_dir = "/var/tmp/" if os.name == "posix" else tempfile.gettempdir()
|
temp_dir = "/var/tmp/" if os.name == "posix" else tempfile.gettempdir()
|
||||||
|
|
||||||
|
|
||||||
def mkdir_p(path: str) -> None:
|
def mkdir_p(path: str) -> None:
|
||||||
# Python doesn't have an analog to `mkdir -p` < Python 3.2.
|
# Python doesn't have an analog to `mkdir -p` < Python 3.2.
|
||||||
try:
|
try:
|
||||||
|
@ -41,14 +44,18 @@ def mkdir_p(path: str) -> None:
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def send_log_zulip(file_name: str, count: int, lines: List[str], extra: str = "") -> None:
|
def send_log_zulip(file_name: str, count: int, lines: List[str], extra: str = "") -> None:
|
||||||
content = "%s new errors%s:\n```\n%s\n```" % (count, extra, "\n".join(lines))
|
content = "%s new errors%s:\n```\n%s\n```" % (count, extra, "\n".join(lines))
|
||||||
zulip_client.send_message({
|
zulip_client.send_message(
|
||||||
"type": "stream",
|
{
|
||||||
"to": "logs",
|
"type": "stream",
|
||||||
"subject": "%s on %s" % (file_name, platform.node()),
|
"to": "logs",
|
||||||
"content": content,
|
"subject": "%s on %s" % (file_name, platform.node()),
|
||||||
})
|
"content": content,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def process_lines(raw_lines: List[str], file_name: str) -> None:
|
def process_lines(raw_lines: List[str], file_name: str) -> None:
|
||||||
lines = []
|
lines = []
|
||||||
|
@ -65,6 +72,7 @@ def process_lines(raw_lines: List[str], file_name: str) -> None:
|
||||||
else:
|
else:
|
||||||
send_log_zulip(file_name, len(lines), lines)
|
send_log_zulip(file_name, len(lines), lines)
|
||||||
|
|
||||||
|
|
||||||
def process_logs() -> None:
|
def process_logs() -> None:
|
||||||
data_file_path = os.path.join(temp_dir, "log2zulip.state")
|
data_file_path = os.path.join(temp_dir, "log2zulip.state")
|
||||||
mkdir_p(os.path.dirname(data_file_path))
|
mkdir_p(os.path.dirname(data_file_path))
|
||||||
|
@ -95,6 +103,7 @@ def process_logs() -> None:
|
||||||
new_data[log_file] = file_data
|
new_data[log_file] = file_data
|
||||||
open(data_file_path, "w").write(json.dumps(new_data))
|
open(data_file_path, "w").write(json.dumps(new_data))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = zulip.add_default_arguments(argparse.ArgumentParser()) # type: argparse.ArgumentParser
|
parser = zulip.add_default_arguments(argparse.ArgumentParser()) # type: argparse.ArgumentParser
|
||||||
parser.add_argument("--control-path", default="/etc/log2zulip.conf")
|
parser.add_argument("--control-path", default="/etc/log2zulip.conf")
|
||||||
|
|
|
@ -17,8 +17,9 @@ for opt in ('type', 'host', 'service', 'state'):
|
||||||
parser.add_argument('--' + opt)
|
parser.add_argument('--' + opt)
|
||||||
opts = parser.parse_args()
|
opts = parser.parse_args()
|
||||||
|
|
||||||
client = zulip.Client(config_file=opts.config,
|
client = zulip.Client(
|
||||||
client="ZulipNagios/" + VERSION) # type: zulip.Client
|
config_file=opts.config, client="ZulipNagios/" + VERSION
|
||||||
|
) # type: zulip.Client
|
||||||
|
|
||||||
msg = dict(type='stream', to=opts.stream) # type: Dict[str, Any]
|
msg = dict(type='stream', to=opts.stream) # type: Dict[str, Any]
|
||||||
|
|
||||||
|
@ -47,6 +48,6 @@ msg['content'] = '**%s**: %s is %s' % (opts.type, thing, opts.state)
|
||||||
output = (opts.output + '\n' + opts.long_output.replace(r'\n', '\n')).strip() # type: Text
|
output = (opts.output + '\n' + opts.long_output.replace(r'\n', '\n')).strip() # type: Text
|
||||||
if output:
|
if output:
|
||||||
# Put any command output in a code block.
|
# Put any command output in a code block.
|
||||||
msg['content'] += ('\n\n~~~~\n' + output + "\n~~~~\n")
|
msg['content'] += '\n\n~~~~\n' + output + "\n~~~~\n"
|
||||||
|
|
||||||
client.send_message(msg)
|
client.send_message(msg)
|
||||||
|
|
|
@ -21,7 +21,9 @@ client = zulip.Client(
|
||||||
email=config.ZULIP_USER,
|
email=config.ZULIP_USER,
|
||||||
site=config.ZULIP_SITE,
|
site=config.ZULIP_SITE,
|
||||||
api_key=config.ZULIP_API_KEY,
|
api_key=config.ZULIP_API_KEY,
|
||||||
client='ZulipOpenShift/' + VERSION)
|
client='ZulipOpenShift/' + VERSION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_deployment_details() -> Dict[str, str]:
|
def get_deployment_details() -> Dict[str, str]:
|
||||||
# "gear deployments" output example:
|
# "gear deployments" output example:
|
||||||
|
@ -30,10 +32,13 @@ def get_deployment_details() -> Dict[str, str]:
|
||||||
dep = subprocess.check_output(['gear', 'deployments'], universal_newlines=True).splitlines()[1]
|
dep = subprocess.check_output(['gear', 'deployments'], universal_newlines=True).splitlines()[1]
|
||||||
splits = dep.split(' - ')
|
splits = dep.split(' - ')
|
||||||
|
|
||||||
return dict(app_name=os.environ['OPENSHIFT_APP_NAME'],
|
return dict(
|
||||||
url=os.environ['OPENSHIFT_APP_DNS'],
|
app_name=os.environ['OPENSHIFT_APP_NAME'],
|
||||||
branch=splits[2],
|
url=os.environ['OPENSHIFT_APP_DNS'],
|
||||||
commit_id=splits[3])
|
branch=splits[2],
|
||||||
|
commit_id=splits[3],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_bot_message(deployment: Dict[str, str]) -> None:
|
def send_bot_message(deployment: Dict[str, str]) -> None:
|
||||||
destination = config.deployment_notice_destination(deployment['branch'])
|
destination = config.deployment_notice_destination(deployment['branch'])
|
||||||
|
@ -42,14 +47,17 @@ def send_bot_message(deployment: Dict[str, str]) -> None:
|
||||||
return
|
return
|
||||||
message = config.format_deployment_message(**deployment)
|
message = config.format_deployment_message(**deployment)
|
||||||
|
|
||||||
client.send_message({
|
client.send_message(
|
||||||
'type': 'stream',
|
{
|
||||||
'to': destination['stream'],
|
'type': 'stream',
|
||||||
'subject': destination['subject'],
|
'to': destination['stream'],
|
||||||
'content': message,
|
'subject': destination['subject'],
|
||||||
})
|
'content': message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
deployment = get_deployment_details()
|
deployment = get_deployment_details()
|
||||||
send_bot_message(deployment)
|
send_bot_message(deployment)
|
||||||
|
|
|
@ -21,12 +21,12 @@ ZULIP_API_KEY = '0123456789abcdef0123456789abcdef'
|
||||||
# And similarly for branch "test-post-receive" (for use when testing).
|
# And similarly for branch "test-post-receive" (for use when testing).
|
||||||
def deployment_notice_destination(branch: str) -> Optional[Dict[str, Text]]:
|
def deployment_notice_destination(branch: str) -> Optional[Dict[str, Text]]:
|
||||||
if branch in ['master', 'test-post-receive']:
|
if branch in ['master', 'test-post-receive']:
|
||||||
return dict(stream = 'deployments',
|
return dict(stream='deployments', subject='%s' % (branch,))
|
||||||
subject = '%s' % (branch,))
|
|
||||||
|
|
||||||
# Return None for cases where you don't want a notice sent
|
# Return None for cases where you don't want a notice sent
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Modify this function to change how deployments are displayed
|
# Modify this function to change how deployments are displayed
|
||||||
#
|
#
|
||||||
# It takes the following arguments:
|
# It takes the following arguments:
|
||||||
|
@ -39,9 +39,15 @@ def deployment_notice_destination(branch: str) -> Optional[Dict[str, Text]]:
|
||||||
# * dep_id = deployment id
|
# * dep_id = deployment id
|
||||||
# * dep_time = deployment timestamp
|
# * dep_time = deployment timestamp
|
||||||
def format_deployment_message(
|
def format_deployment_message(
|
||||||
app_name: str = '', url: str = '', branch: str = '', commit_id: str = '', dep_id: str = '', dep_time: str = '') -> str:
|
app_name: str = '',
|
||||||
return 'Deployed commit `%s` (%s) in [%s](%s)' % (
|
url: str = '',
|
||||||
commit_id, branch, app_name, url)
|
branch: str = '',
|
||||||
|
commit_id: str = '',
|
||||||
|
dep_id: str = '',
|
||||||
|
dep_time: str = '',
|
||||||
|
) -> str:
|
||||||
|
return 'Deployed commit `%s` (%s) in [%s](%s)' % (commit_id, branch, app_name, url)
|
||||||
|
|
||||||
|
|
||||||
## If properly installed, the Zulip API should be in your import
|
## If properly installed, the Zulip API should be in your import
|
||||||
## path, but if not, set a custom path below
|
## path, but if not, set a custom path below
|
||||||
|
|
|
@ -36,7 +36,8 @@ client = zulip.Client(
|
||||||
email=config.ZULIP_USER,
|
email=config.ZULIP_USER,
|
||||||
site=config.ZULIP_SITE,
|
site=config.ZULIP_SITE,
|
||||||
api_key=config.ZULIP_API_KEY,
|
api_key=config.ZULIP_API_KEY,
|
||||||
client="ZulipPerforce/" + __version__) # type: zulip.Client
|
client="ZulipPerforce/" + __version__,
|
||||||
|
) # type: zulip.Client
|
||||||
|
|
||||||
try:
|
try:
|
||||||
changelist = int(sys.argv[1]) # type: int
|
changelist = int(sys.argv[1]) # type: int
|
||||||
|
@ -52,7 +53,9 @@ except ValueError:
|
||||||
|
|
||||||
metadata = git_p4.p4_describe(changelist) # type: Dict[str, str]
|
metadata = git_p4.p4_describe(changelist) # type: Dict[str, str]
|
||||||
|
|
||||||
destination = config.commit_notice_destination(changeroot, changelist) # type: Optional[Dict[str, str]]
|
destination = config.commit_notice_destination(
|
||||||
|
changeroot, changelist
|
||||||
|
) # type: Optional[Dict[str, str]]
|
||||||
|
|
||||||
if destination is None:
|
if destination is None:
|
||||||
# Don't forward the notice anywhere
|
# Don't forward the notice anywhere
|
||||||
|
@ -84,10 +87,8 @@ message = """**{user}** committed revision @{change} to `{path}`.
|
||||||
{desc}
|
{desc}
|
||||||
```
|
```
|
||||||
""".format(
|
""".format(
|
||||||
user=metadata["user"],
|
user=metadata["user"], change=change, path=changeroot, desc=metadata["desc"]
|
||||||
change=change,
|
) # type: str
|
||||||
path=changeroot,
|
|
||||||
desc=metadata["desc"]) # type: str
|
|
||||||
|
|
||||||
message_data = {
|
message_data = {
|
||||||
"type": "stream",
|
"type": "stream",
|
||||||
|
|
|
@ -37,12 +37,12 @@ def commit_notice_destination(path: Text, changelist: int) -> Optional[Dict[Text
|
||||||
directory = dirs[2]
|
directory = dirs[2]
|
||||||
|
|
||||||
if directory not in ["evil-master-plan", "my-super-secret-repository"]:
|
if directory not in ["evil-master-plan", "my-super-secret-repository"]:
|
||||||
return dict(stream = "%s-commits" % (directory,),
|
return dict(stream="%s-commits" % (directory,), subject=path)
|
||||||
subject = path)
|
|
||||||
|
|
||||||
# Return None for cases where you don't want a notice sent
|
# Return None for cases where you don't want a notice sent
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
## If properly installed, the Zulip API should be in your import
|
## If properly installed, the Zulip API should be in your import
|
||||||
## path, but if not, set a custom path below
|
## path, but if not, set a custom path below
|
||||||
ZULIP_API_PATH: Optional[str] = None
|
ZULIP_API_PATH: Optional[str] = None
|
||||||
|
|
|
@ -48,35 +48,48 @@ stream every 5 minutes is:
|
||||||
|
|
||||||
*/5 * * * * /usr/local/share/zulip/integrations/rss/rss-bot"""
|
*/5 * * * * /usr/local/share/zulip/integrations/rss/rss-bot"""
|
||||||
|
|
||||||
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage)) # type: argparse.ArgumentParser
|
parser = zulip.add_default_arguments(
|
||||||
parser.add_argument('--stream',
|
argparse.ArgumentParser(usage)
|
||||||
dest='stream',
|
) # type: argparse.ArgumentParser
|
||||||
help='The stream to which to send RSS messages.',
|
parser.add_argument(
|
||||||
default="rss",
|
'--stream',
|
||||||
action='store')
|
dest='stream',
|
||||||
parser.add_argument('--data-dir',
|
help='The stream to which to send RSS messages.',
|
||||||
dest='data_dir',
|
default="rss",
|
||||||
help='The directory where feed metadata is stored',
|
action='store',
|
||||||
default=os.path.join(RSS_DATA_DIR),
|
)
|
||||||
action='store')
|
parser.add_argument(
|
||||||
parser.add_argument('--feed-file',
|
'--data-dir',
|
||||||
dest='feed_file',
|
dest='data_dir',
|
||||||
help='The file containing a list of RSS feed URLs to follow, one URL per line',
|
help='The directory where feed metadata is stored',
|
||||||
default=os.path.join(RSS_DATA_DIR, "rss-feeds"),
|
default=os.path.join(RSS_DATA_DIR),
|
||||||
action='store')
|
action='store',
|
||||||
parser.add_argument('--unwrap',
|
)
|
||||||
dest='unwrap',
|
parser.add_argument(
|
||||||
action='store_true',
|
'--feed-file',
|
||||||
help='Convert word-wrapped paragraphs into single lines',
|
dest='feed_file',
|
||||||
default=False)
|
help='The file containing a list of RSS feed URLs to follow, one URL per line',
|
||||||
parser.add_argument('--math',
|
default=os.path.join(RSS_DATA_DIR, "rss-feeds"),
|
||||||
dest='math',
|
action='store',
|
||||||
action='store_true',
|
)
|
||||||
help='Convert $ to $$ (for KaTeX processing)',
|
parser.add_argument(
|
||||||
default=False)
|
'--unwrap',
|
||||||
|
dest='unwrap',
|
||||||
|
action='store_true',
|
||||||
|
help='Convert word-wrapped paragraphs into single lines',
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--math',
|
||||||
|
dest='math',
|
||||||
|
action='store_true',
|
||||||
|
help='Convert $ to $$ (for KaTeX processing)',
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
opts = parser.parse_args() # type: Any
|
opts = parser.parse_args() # type: Any
|
||||||
|
|
||||||
|
|
||||||
def mkdir_p(path: str) -> None:
|
def mkdir_p(path: str) -> None:
|
||||||
# Python doesn't have an analog to `mkdir -p` < Python 3.2.
|
# Python doesn't have an analog to `mkdir -p` < Python 3.2.
|
||||||
try:
|
try:
|
||||||
|
@ -87,6 +100,7 @@ def mkdir_p(path: str) -> None:
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mkdir_p(opts.data_dir)
|
mkdir_p(opts.data_dir)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
@ -106,11 +120,13 @@ logger = logging.getLogger(__name__) # type: logging.Logger
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
logger.addHandler(file_handler)
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
|
||||||
def log_error_and_exit(error: str) -> None:
|
def log_error_and_exit(error: str) -> None:
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
logger.error(usage)
|
logger.error(usage)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
class MLStripper(HTMLParser):
|
class MLStripper(HTMLParser):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
@ -123,57 +139,70 @@ class MLStripper(HTMLParser):
|
||||||
def get_data(self) -> str:
|
def get_data(self) -> str:
|
||||||
return ''.join(self.fed)
|
return ''.join(self.fed)
|
||||||
|
|
||||||
|
|
||||||
def strip_tags(html: str) -> str:
|
def strip_tags(html: str) -> str:
|
||||||
stripper = MLStripper()
|
stripper = MLStripper()
|
||||||
stripper.feed(html)
|
stripper.feed(html)
|
||||||
return stripper.get_data()
|
return stripper.get_data()
|
||||||
|
|
||||||
|
|
||||||
def compute_entry_hash(entry: Dict[str, Any]) -> str:
|
def compute_entry_hash(entry: Dict[str, Any]) -> str:
|
||||||
entry_time = entry.get("published", entry.get("updated"))
|
entry_time = entry.get("published", entry.get("updated"))
|
||||||
entry_id = entry.get("id", entry.get("link"))
|
entry_id = entry.get("id", entry.get("link"))
|
||||||
return hashlib.md5((entry_id + str(entry_time)).encode()).hexdigest()
|
return hashlib.md5((entry_id + str(entry_time)).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def unwrap_text(body: str) -> str:
|
def unwrap_text(body: str) -> str:
|
||||||
# Replace \n by space if it is preceded and followed by a non-\n.
|
# Replace \n by space if it is preceded and followed by a non-\n.
|
||||||
# Example: '\na\nb\nc\n\nd\n' -> '\na b c\n\nd\n'
|
# Example: '\na\nb\nc\n\nd\n' -> '\na b c\n\nd\n'
|
||||||
return re.sub('(?<=[^\n])\n(?=[^\n])', ' ', body)
|
return re.sub('(?<=[^\n])\n(?=[^\n])', ' ', body)
|
||||||
|
|
||||||
|
|
||||||
def elide_subject(subject: str) -> str:
|
def elide_subject(subject: str) -> str:
|
||||||
MAX_TOPIC_LENGTH = 60
|
MAX_TOPIC_LENGTH = 60
|
||||||
if len(subject) > MAX_TOPIC_LENGTH:
|
if len(subject) > MAX_TOPIC_LENGTH:
|
||||||
subject = subject[:MAX_TOPIC_LENGTH - 3].rstrip() + '...'
|
subject = subject[: MAX_TOPIC_LENGTH - 3].rstrip() + '...'
|
||||||
return subject
|
return subject
|
||||||
|
|
||||||
|
|
||||||
def send_zulip(entry: Any, feed_name: str) -> Dict[str, Any]:
|
def send_zulip(entry: Any, feed_name: str) -> Dict[str, Any]:
|
||||||
body = entry.summary # type: str
|
body = entry.summary # type: str
|
||||||
if opts.unwrap:
|
if opts.unwrap:
|
||||||
body = unwrap_text(body)
|
body = unwrap_text(body)
|
||||||
|
|
||||||
content = "**[%s](%s)**\n%s\n%s" % (entry.title,
|
content = "**[%s](%s)**\n%s\n%s" % (
|
||||||
entry.link,
|
entry.title,
|
||||||
strip_tags(body),
|
entry.link,
|
||||||
entry.link) # type: str
|
strip_tags(body),
|
||||||
|
entry.link,
|
||||||
|
) # type: str
|
||||||
|
|
||||||
if opts.math:
|
if opts.math:
|
||||||
content = content.replace('$', '$$')
|
content = content.replace('$', '$$')
|
||||||
|
|
||||||
message = {"type": "stream",
|
message = {
|
||||||
"sender": opts.zulip_email,
|
"type": "stream",
|
||||||
"to": opts.stream,
|
"sender": opts.zulip_email,
|
||||||
"subject": elide_subject(feed_name),
|
"to": opts.stream,
|
||||||
"content": content,
|
"subject": elide_subject(feed_name),
|
||||||
} # type: Dict[str, str]
|
"content": content,
|
||||||
|
} # type: Dict[str, str]
|
||||||
return client.send_message(message)
|
return client.send_message(message)
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(opts.feed_file) as f:
|
with open(opts.feed_file) as f:
|
||||||
feed_urls = [feed.strip() for feed in f.readlines()] # type: List[str]
|
feed_urls = [feed.strip() for feed in f.readlines()] # type: List[str]
|
||||||
except OSError:
|
except OSError:
|
||||||
log_error_and_exit("Unable to read feed file at %s." % (opts.feed_file,))
|
log_error_and_exit("Unable to read feed file at %s." % (opts.feed_file,))
|
||||||
|
|
||||||
client = zulip.Client(email=opts.zulip_email, api_key=opts.zulip_api_key,
|
client = zulip.Client(
|
||||||
config_file=opts.zulip_config_file,
|
email=opts.zulip_email,
|
||||||
site=opts.zulip_site, client="ZulipRSS/" + VERSION) # type: zulip.Client
|
api_key=opts.zulip_api_key,
|
||||||
|
config_file=opts.zulip_config_file,
|
||||||
|
site=opts.zulip_site,
|
||||||
|
client="ZulipRSS/" + VERSION,
|
||||||
|
) # type: zulip.Client
|
||||||
|
|
||||||
first_message = True # type: bool
|
first_message = True # type: bool
|
||||||
|
|
||||||
|
@ -182,7 +211,9 @@ for feed_url in feed_urls:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(feed_file) as f:
|
with open(feed_file) as f:
|
||||||
old_feed_hashes = {line.strip(): True for line in f.readlines()} # type: Dict[str, bool]
|
old_feed_hashes = {
|
||||||
|
line.strip(): True for line in f.readlines()
|
||||||
|
} # type: Dict[str, bool]
|
||||||
except OSError:
|
except OSError:
|
||||||
old_feed_hashes = {}
|
old_feed_hashes = {}
|
||||||
|
|
||||||
|
@ -192,8 +223,13 @@ for feed_url in feed_urls:
|
||||||
for entry in data.entries:
|
for entry in data.entries:
|
||||||
entry_hash = compute_entry_hash(entry) # type: str
|
entry_hash = compute_entry_hash(entry) # type: str
|
||||||
# An entry has either been published or updated.
|
# An entry has either been published or updated.
|
||||||
entry_time = entry.get("published_parsed", entry.get("updated_parsed")) # type: Tuple[int, int]
|
entry_time = entry.get(
|
||||||
if entry_time is not None and (time.time() - calendar.timegm(entry_time)) > OLDNESS_THRESHOLD * 60 * 60 * 24:
|
"published_parsed", entry.get("updated_parsed")
|
||||||
|
) # type: Tuple[int, int]
|
||||||
|
if (
|
||||||
|
entry_time is not None
|
||||||
|
and (time.time() - calendar.timegm(entry_time)) > OLDNESS_THRESHOLD * 60 * 60 * 24
|
||||||
|
):
|
||||||
# As a safeguard against misbehaving feeds, don't try to process
|
# As a safeguard against misbehaving feeds, don't try to process
|
||||||
# entries older than some threshold.
|
# entries older than some threshold.
|
||||||
continue
|
continue
|
||||||
|
|
|
@ -30,7 +30,8 @@ client = zulip.Client(
|
||||||
email=config.ZULIP_USER,
|
email=config.ZULIP_USER,
|
||||||
site=config.ZULIP_SITE,
|
site=config.ZULIP_SITE,
|
||||||
api_key=config.ZULIP_API_KEY,
|
api_key=config.ZULIP_API_KEY,
|
||||||
client="ZulipSVN/" + VERSION) # type: zulip.Client
|
client="ZulipSVN/" + VERSION,
|
||||||
|
) # type: zulip.Client
|
||||||
svn = pysvn.Client() # type: pysvn.Client
|
svn = pysvn.Client() # type: pysvn.Client
|
||||||
|
|
||||||
path, rev = sys.argv[1:] # type: Tuple[Text, Text]
|
path, rev = sys.argv[1:] # type: Tuple[Text, Text]
|
||||||
|
@ -38,12 +39,12 @@ path, rev = sys.argv[1:] # type: Tuple[Text, Text]
|
||||||
# since its a local path, prepend "file://"
|
# since its a local path, prepend "file://"
|
||||||
path = "file://" + path
|
path = "file://" + path
|
||||||
|
|
||||||
entry = svn.log(path, revision_end=pysvn.Revision(pysvn.opt_revision_kind.number, rev))[0] # type: Dict[Text, Any]
|
entry = svn.log(path, revision_end=pysvn.Revision(pysvn.opt_revision_kind.number, rev))[
|
||||||
|
0
|
||||||
|
] # type: Dict[Text, Any]
|
||||||
message = "**{}** committed revision r{} to `{}`.\n\n> {}".format(
|
message = "**{}** committed revision r{} to `{}`.\n\n> {}".format(
|
||||||
entry['author'],
|
entry['author'], rev, path.split('/')[-1], entry['revprops']['svn:log']
|
||||||
rev,
|
) # type: Text
|
||||||
path.split('/')[-1],
|
|
||||||
entry['revprops']['svn:log']) # type: Text
|
|
||||||
|
|
||||||
destination = config.commit_notice_destination(path, rev) # type: Optional[Dict[Text, Text]]
|
destination = config.commit_notice_destination(path, rev) # type: Optional[Dict[Text, Text]]
|
||||||
|
|
||||||
|
|
|
@ -21,12 +21,12 @@ ZULIP_API_KEY = "0123456789abcdef0123456789abcdef"
|
||||||
def commit_notice_destination(path: Text, commit: Text) -> Optional[Dict[Text, Text]]:
|
def commit_notice_destination(path: Text, commit: Text) -> Optional[Dict[Text, Text]]:
|
||||||
repo = path.split('/')[-1]
|
repo = path.split('/')[-1]
|
||||||
if repo not in ["evil-master-plan", "my-super-secret-repository"]:
|
if repo not in ["evil-master-plan", "my-super-secret-repository"]:
|
||||||
return dict(stream = "commits",
|
return dict(stream="commits", subject="%s" % (repo,))
|
||||||
subject = "%s" % (repo,))
|
|
||||||
|
|
||||||
# Return None for cases where you don't want a notice sent
|
# Return None for cases where you don't want a notice sent
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
## If properly installed, the Zulip API should be in your import
|
## If properly installed, the Zulip API should be in your import
|
||||||
## path, but if not, set a custom path below
|
## path, but if not, set a custom path below
|
||||||
ZULIP_API_PATH: Optional[str] = None
|
ZULIP_API_PATH: Optional[str] = None
|
||||||
|
|
|
@ -33,38 +33,50 @@ client = zulip.Client(
|
||||||
email=config.ZULIP_USER,
|
email=config.ZULIP_USER,
|
||||||
site=config.ZULIP_SITE,
|
site=config.ZULIP_SITE,
|
||||||
api_key=config.ZULIP_API_KEY,
|
api_key=config.ZULIP_API_KEY,
|
||||||
client="ZulipTrac/" + VERSION)
|
client="ZulipTrac/" + VERSION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def markdown_ticket_url(ticket: Any, heading: str = "ticket") -> str:
|
def markdown_ticket_url(ticket: Any, heading: str = "ticket") -> str:
|
||||||
return "[%s #%s](%s/%s)" % (heading, ticket.id, config.TRAC_BASE_TICKET_URL, ticket.id)
|
return "[%s #%s](%s/%s)" % (heading, ticket.id, config.TRAC_BASE_TICKET_URL, ticket.id)
|
||||||
|
|
||||||
|
|
||||||
def markdown_block(desc: str) -> str:
|
def markdown_block(desc: str) -> str:
|
||||||
return "\n\n>" + "\n> ".join(desc.split("\n")) + "\n"
|
return "\n\n>" + "\n> ".join(desc.split("\n")) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def truncate(string: str, length: int) -> str:
|
def truncate(string: str, length: int) -> str:
|
||||||
if len(string) <= length:
|
if len(string) <= length:
|
||||||
return string
|
return string
|
||||||
return string[:length - 3] + "..."
|
return string[: length - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
def trac_subject(ticket: Any) -> str:
|
def trac_subject(ticket: Any) -> str:
|
||||||
return truncate("#%s: %s" % (ticket.id, ticket.values.get("summary")), 60)
|
return truncate("#%s: %s" % (ticket.id, ticket.values.get("summary")), 60)
|
||||||
|
|
||||||
|
|
||||||
def send_update(ticket: Any, content: str) -> None:
|
def send_update(ticket: Any, content: str) -> None:
|
||||||
client.send_message({
|
client.send_message(
|
||||||
"type": "stream",
|
{
|
||||||
"to": config.STREAM_FOR_NOTIFICATIONS,
|
"type": "stream",
|
||||||
"content": content,
|
"to": config.STREAM_FOR_NOTIFICATIONS,
|
||||||
"subject": trac_subject(ticket)
|
"content": content,
|
||||||
})
|
"subject": trac_subject(ticket),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ZulipPlugin(Component):
|
class ZulipPlugin(Component):
|
||||||
implements(ITicketChangeListener)
|
implements(ITicketChangeListener)
|
||||||
|
|
||||||
def ticket_created(self, ticket: Any) -> None:
|
def ticket_created(self, ticket: Any) -> None:
|
||||||
"""Called when a ticket is created."""
|
"""Called when a ticket is created."""
|
||||||
content = "%s created %s in component **%s**, priority **%s**:\n" % \
|
content = "%s created %s in component **%s**, priority **%s**:\n" % (
|
||||||
(ticket.values.get("reporter"), markdown_ticket_url(ticket),
|
ticket.values.get("reporter"),
|
||||||
ticket.values.get("component"), ticket.values.get("priority"))
|
markdown_ticket_url(ticket),
|
||||||
|
ticket.values.get("component"),
|
||||||
|
ticket.values.get("priority"),
|
||||||
|
)
|
||||||
# Include the full subject if it will be truncated
|
# Include the full subject if it will be truncated
|
||||||
if len(ticket.values.get("summary")) > 60:
|
if len(ticket.values.get("summary")) > 60:
|
||||||
content += "**%s**\n" % (ticket.values.get("summary"),)
|
content += "**%s**\n" % (ticket.values.get("summary"),)
|
||||||
|
@ -72,7 +84,9 @@ class ZulipPlugin(Component):
|
||||||
content += "%s" % (markdown_block(ticket.values.get("description")),)
|
content += "%s" % (markdown_block(ticket.values.get("description")),)
|
||||||
send_update(ticket, content)
|
send_update(ticket, content)
|
||||||
|
|
||||||
def ticket_changed(self, ticket: Any, comment: str, author: str, old_values: Dict[str, Any]) -> None:
|
def ticket_changed(
|
||||||
|
self, ticket: Any, comment: str, author: str, old_values: Dict[str, Any]
|
||||||
|
) -> None:
|
||||||
"""Called when a ticket is modified.
|
"""Called when a ticket is modified.
|
||||||
|
|
||||||
`old_values` is a dictionary containing the previous values of the
|
`old_values` is a dictionary containing the previous values of the
|
||||||
|
@ -92,15 +106,19 @@ class ZulipPlugin(Component):
|
||||||
field_changes = []
|
field_changes = []
|
||||||
for key, value in old_values.items():
|
for key, value in old_values.items():
|
||||||
if key == "description":
|
if key == "description":
|
||||||
content += '- Changed %s from %s\n\nto %s' % (key, markdown_block(value),
|
content += '- Changed %s from %s\n\nto %s' % (
|
||||||
markdown_block(ticket.values.get(key)))
|
key,
|
||||||
|
markdown_block(value),
|
||||||
|
markdown_block(ticket.values.get(key)),
|
||||||
|
)
|
||||||
elif old_values.get(key) == "":
|
elif old_values.get(key) == "":
|
||||||
field_changes.append('%s: => **%s**' % (key, ticket.values.get(key)))
|
field_changes.append('%s: => **%s**' % (key, ticket.values.get(key)))
|
||||||
elif ticket.values.get(key) == "":
|
elif ticket.values.get(key) == "":
|
||||||
field_changes.append('%s: **%s** => ""' % (key, old_values.get(key)))
|
field_changes.append('%s: **%s** => ""' % (key, old_values.get(key)))
|
||||||
else:
|
else:
|
||||||
field_changes.append('%s: **%s** => **%s**' % (key, old_values.get(key),
|
field_changes.append(
|
||||||
ticket.values.get(key)))
|
'%s: **%s** => **%s**' % (key, old_values.get(key), ticket.values.get(key))
|
||||||
|
)
|
||||||
content += ", ".join(field_changes)
|
content += ", ".join(field_changes)
|
||||||
|
|
||||||
send_update(ticket, content)
|
send_update(ticket, content)
|
||||||
|
|
|
@ -13,6 +13,7 @@ except ImportError:
|
||||||
print("http://docs.python-requests.org/en/master/user/install/")
|
print("http://docs.python-requests.org/en/master/user/install/")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def get_model_id(options):
|
def get_model_id(options):
|
||||||
"""get_model_id
|
"""get_model_id
|
||||||
|
|
||||||
|
@ -24,19 +25,14 @@ def get_model_id(options):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
trello_api_url = 'https://api.trello.com/1/board/{}'.format(
|
trello_api_url = 'https://api.trello.com/1/board/{}'.format(options.trello_board_id)
|
||||||
options.trello_board_id
|
|
||||||
)
|
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'key': options.trello_api_key,
|
'key': options.trello_api_key,
|
||||||
'token': options.trello_token,
|
'token': options.trello_token,
|
||||||
}
|
}
|
||||||
|
|
||||||
trello_response = requests.get(
|
trello_response = requests.get(trello_api_url, params=params)
|
||||||
trello_api_url,
|
|
||||||
params=params
|
|
||||||
)
|
|
||||||
|
|
||||||
if trello_response.status_code != 200:
|
if trello_response.status_code != 200:
|
||||||
print('Error: Can\'t get the idModel. Please check the configuration')
|
print('Error: Can\'t get the idModel. Please check the configuration')
|
||||||
|
@ -68,13 +64,10 @@ def get_webhook_id(options, id_model):
|
||||||
options.trello_board_name,
|
options.trello_board_name,
|
||||||
),
|
),
|
||||||
'callbackURL': options.zulip_webhook_url,
|
'callbackURL': options.zulip_webhook_url,
|
||||||
'idModel': id_model
|
'idModel': id_model,
|
||||||
}
|
}
|
||||||
|
|
||||||
trello_response = requests.post(
|
trello_response = requests.post(trello_api_url, data=data)
|
||||||
trello_api_url,
|
|
||||||
data=data
|
|
||||||
)
|
|
||||||
|
|
||||||
if trello_response.status_code != 200:
|
if trello_response.status_code != 200:
|
||||||
print('Error: Can\'t create the Webhook:', trello_response.text)
|
print('Error: Can\'t create the Webhook:', trello_response.text)
|
||||||
|
@ -84,6 +77,7 @@ def get_webhook_id(options, id_model):
|
||||||
|
|
||||||
return webhook_info_json['id']
|
return webhook_info_json['id']
|
||||||
|
|
||||||
|
|
||||||
def create_webhook(options):
|
def create_webhook(options):
|
||||||
"""create_webhook
|
"""create_webhook
|
||||||
|
|
||||||
|
@ -106,8 +100,12 @@ def create_webhook(options):
|
||||||
if id_webhook:
|
if id_webhook:
|
||||||
print('Success! The webhook ID is', id_webhook)
|
print('Success! The webhook ID is', id_webhook)
|
||||||
|
|
||||||
print('Success! The webhook for the {} Trello board was successfully created.'.format(
|
print(
|
||||||
options.trello_board_name))
|
'Success! The webhook for the {} Trello board was successfully created.'.format(
|
||||||
|
options.trello_board_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
description = """
|
description = """
|
||||||
|
@ -120,28 +118,36 @@ at <https://zulip.com/integrations/doc/trello>.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description=description)
|
parser = argparse.ArgumentParser(description=description)
|
||||||
parser.add_argument('--trello-board-name',
|
parser.add_argument('--trello-board-name', required=True, help='The Trello board name.')
|
||||||
required=True,
|
parser.add_argument(
|
||||||
help='The Trello board name.')
|
'--trello-board-id',
|
||||||
parser.add_argument('--trello-board-id',
|
required=True,
|
||||||
required=True,
|
help=('The Trello board short ID. Can usually be found ' 'in the URL of the Trello board.'),
|
||||||
help=('The Trello board short ID. Can usually be found '
|
)
|
||||||
'in the URL of the Trello board.'))
|
parser.add_argument(
|
||||||
parser.add_argument('--trello-api-key',
|
'--trello-api-key',
|
||||||
required=True,
|
required=True,
|
||||||
help=('Visit https://trello.com/1/appkey/generate to generate '
|
help=(
|
||||||
'an APPLICATION_KEY (need to be logged into Trello).'))
|
'Visit https://trello.com/1/appkey/generate to generate '
|
||||||
parser.add_argument('--trello-token',
|
'an APPLICATION_KEY (need to be logged into Trello).'
|
||||||
required=True,
|
),
|
||||||
help=('Visit https://trello.com/1/appkey/generate and under '
|
)
|
||||||
'`Developer API Keys`, click on `Token` and generate '
|
parser.add_argument(
|
||||||
'a Trello access token.'))
|
'--trello-token',
|
||||||
parser.add_argument('--zulip-webhook-url',
|
required=True,
|
||||||
required=True,
|
help=(
|
||||||
help='The webhook URL that Trello will query.')
|
'Visit https://trello.com/1/appkey/generate and under '
|
||||||
|
'`Developer API Keys`, click on `Token` and generate '
|
||||||
|
'a Trello access token.'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--zulip-webhook-url', required=True, help='The webhook URL that Trello will query.'
|
||||||
|
)
|
||||||
|
|
||||||
options = parser.parse_args()
|
options = parser.parse_args()
|
||||||
create_webhook(options)
|
create_webhook(options)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
|
@ -67,37 +67,34 @@ Make sure to go the application you created and click "create my
|
||||||
access token" as well. Fill in the values displayed.
|
access token" as well. Fill in the values displayed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def write_config(config: ConfigParser, configfile_path: str) -> None:
|
def write_config(config: ConfigParser, configfile_path: str) -> None:
|
||||||
with open(configfile_path, 'w') as configfile:
|
with open(configfile_path, 'w') as configfile:
|
||||||
config.write(configfile)
|
config.write(configfile)
|
||||||
|
|
||||||
|
|
||||||
parser = zulip.add_default_arguments(argparse.ArgumentParser("Fetch tweets from Twitter."))
|
parser = zulip.add_default_arguments(argparse.ArgumentParser("Fetch tweets from Twitter."))
|
||||||
parser.add_argument('--instructions',
|
parser.add_argument(
|
||||||
action='store_true',
|
'--instructions',
|
||||||
help='Show instructions for the twitter bot setup and exit'
|
action='store_true',
|
||||||
)
|
help='Show instructions for the twitter bot setup and exit',
|
||||||
parser.add_argument('--limit-tweets',
|
)
|
||||||
default=15,
|
parser.add_argument(
|
||||||
type=int,
|
'--limit-tweets', default=15, type=int, help='Maximum number of tweets to send at once'
|
||||||
help='Maximum number of tweets to send at once')
|
)
|
||||||
parser.add_argument('--search',
|
parser.add_argument('--search', dest='search_terms', help='Terms to search on', action='store')
|
||||||
dest='search_terms',
|
parser.add_argument(
|
||||||
help='Terms to search on',
|
'--stream',
|
||||||
action='store')
|
dest='stream',
|
||||||
parser.add_argument('--stream',
|
help='The stream to which to send tweets',
|
||||||
dest='stream',
|
default="twitter",
|
||||||
help='The stream to which to send tweets',
|
action='store',
|
||||||
default="twitter",
|
)
|
||||||
action='store')
|
parser.add_argument(
|
||||||
parser.add_argument('--twitter-name',
|
'--twitter-name', dest='twitter_name', help='Twitter username to poll new tweets from"'
|
||||||
dest='twitter_name',
|
)
|
||||||
help='Twitter username to poll new tweets from"')
|
parser.add_argument('--excluded-terms', dest='excluded_terms', help='Terms to exclude tweets on')
|
||||||
parser.add_argument('--excluded-terms',
|
parser.add_argument('--excluded-users', dest='excluded_users', help='Users to exclude tweets on')
|
||||||
dest='excluded_terms',
|
|
||||||
help='Terms to exclude tweets on')
|
|
||||||
parser.add_argument('--excluded-users',
|
|
||||||
dest='excluded_users',
|
|
||||||
help='Users to exclude tweets on')
|
|
||||||
|
|
||||||
opts = parser.parse_args()
|
opts = parser.parse_args()
|
||||||
|
|
||||||
|
@ -150,18 +147,22 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
parser.error("Please install python-twitter")
|
parser.error("Please install python-twitter")
|
||||||
|
|
||||||
api = twitter.Api(consumer_key=consumer_key,
|
api = twitter.Api(
|
||||||
consumer_secret=consumer_secret,
|
consumer_key=consumer_key,
|
||||||
access_token_key=access_token_key,
|
consumer_secret=consumer_secret,
|
||||||
access_token_secret=access_token_secret)
|
access_token_key=access_token_key,
|
||||||
|
access_token_secret=access_token_secret,
|
||||||
|
)
|
||||||
|
|
||||||
user = api.VerifyCredentials()
|
user = api.VerifyCredentials()
|
||||||
|
|
||||||
if not user.id:
|
if not user.id:
|
||||||
print("Unable to log in to twitter with supplied credentials. Please double-check and try again")
|
print(
|
||||||
|
"Unable to log in to twitter with supplied credentials. Please double-check and try again"
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
client = zulip.init_from_options(opts, client=client_type+VERSION)
|
client = zulip.init_from_options(opts, client=client_type + VERSION)
|
||||||
|
|
||||||
if opts.search_terms:
|
if opts.search_terms:
|
||||||
search_query = " OR ".join(opts.search_terms.split(","))
|
search_query = " OR ".join(opts.search_terms.split(","))
|
||||||
|
@ -190,7 +191,7 @@ if opts.excluded_users:
|
||||||
else:
|
else:
|
||||||
excluded_users = []
|
excluded_users = []
|
||||||
|
|
||||||
for status in statuses[::-1][:opts.limit_tweets]:
|
for status in statuses[::-1][: opts.limit_tweets]:
|
||||||
# Check if the tweet is from an excluded user
|
# Check if the tweet is from an excluded user
|
||||||
exclude = False
|
exclude = False
|
||||||
for user in excluded_users:
|
for user in excluded_users:
|
||||||
|
@ -237,12 +238,7 @@ for status in statuses[::-1][:opts.limit_tweets]:
|
||||||
elif opts.twitter_name:
|
elif opts.twitter_name:
|
||||||
subject = composed
|
subject = composed
|
||||||
|
|
||||||
message = {
|
message = {"type": "stream", "to": [opts.stream], "subject": subject, "content": url}
|
||||||
"type": "stream",
|
|
||||||
"to": [opts.stream],
|
|
||||||
"subject": subject,
|
|
||||||
"content": url
|
|
||||||
}
|
|
||||||
|
|
||||||
ret = client.send_message(message)
|
ret = client.send_message(message)
|
||||||
|
|
||||||
|
|
|
@ -13,36 +13,25 @@ import zephyr
|
||||||
import zulip
|
import zulip
|
||||||
|
|
||||||
parser = optparse.OptionParser()
|
parser = optparse.OptionParser()
|
||||||
parser.add_option('--verbose',
|
parser.add_option('--verbose', dest='verbose', default=False, action='store_true')
|
||||||
dest='verbose',
|
parser.add_option('--site', dest='site', default=None, action='store')
|
||||||
default=False,
|
parser.add_option('--sharded', default=False, action='store_true')
|
||||||
action='store_true')
|
|
||||||
parser.add_option('--site',
|
|
||||||
dest='site',
|
|
||||||
default=None,
|
|
||||||
action='store')
|
|
||||||
parser.add_option('--sharded',
|
|
||||||
default=False,
|
|
||||||
action='store_true')
|
|
||||||
(options, args) = parser.parse_args()
|
(options, args) = parser.parse_args()
|
||||||
|
|
||||||
mit_user = 'tabbott/extra@ATHENA.MIT.EDU'
|
mit_user = 'tabbott/extra@ATHENA.MIT.EDU'
|
||||||
|
|
||||||
zulip_client = zulip.Client(
|
zulip_client = zulip.Client(verbose=True, client="ZulipMonitoring/0.1", site=options.site)
|
||||||
verbose=True,
|
|
||||||
client="ZulipMonitoring/0.1",
|
|
||||||
site=options.site)
|
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
log_file = "/var/log/zulip/check-mirroring-log"
|
log_file = "/var/log/zulip/check-mirroring-log"
|
||||||
log_format = "%(asctime)s: %(message)s"
|
log_format = "%(asctime)s: %(message)s"
|
||||||
logging.basicConfig(format=log_format)
|
logging.basicConfig(format=log_format)
|
||||||
|
|
||||||
formatter = logging.Formatter(log_format)
|
formatter = logging.Formatter(log_format)
|
||||||
file_handler = logging.FileHandler(log_file)
|
file_handler = logging.FileHandler(log_file)
|
||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
logger.addHandler(file_handler)
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
@ -75,13 +64,14 @@ if options.sharded:
|
||||||
for (stream, test) in test_streams:
|
for (stream, test) in test_streams:
|
||||||
if stream == "message":
|
if stream == "message":
|
||||||
continue
|
continue
|
||||||
assert(hashlib.sha1(stream.encode("utf-8")).hexdigest().startswith(test))
|
assert hashlib.sha1(stream.encode("utf-8")).hexdigest().startswith(test)
|
||||||
else:
|
else:
|
||||||
test_streams = [
|
test_streams = [
|
||||||
("message", "p"),
|
("message", "p"),
|
||||||
("tabbott-nagios-test", "a"),
|
("tabbott-nagios-test", "a"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def print_status_and_exit(status: int) -> None:
|
def print_status_and_exit(status: int) -> None:
|
||||||
|
|
||||||
# The output of this script is used by Nagios. Various outputs,
|
# The output of this script is used by Nagios. Various outputs,
|
||||||
|
@ -91,6 +81,7 @@ def print_status_and_exit(status: int) -> None:
|
||||||
print(status)
|
print(status)
|
||||||
sys.exit(status)
|
sys.exit(status)
|
||||||
|
|
||||||
|
|
||||||
def send_zulip(message: Dict[str, str]) -> None:
|
def send_zulip(message: Dict[str, str]) -> None:
|
||||||
result = zulip_client.send_message(message)
|
result = zulip_client.send_message(message)
|
||||||
if result["result"] != "success":
|
if result["result"] != "success":
|
||||||
|
@ -99,11 +90,16 @@ def send_zulip(message: Dict[str, str]) -> None:
|
||||||
logger.error(str(result))
|
logger.error(str(result))
|
||||||
print_status_and_exit(1)
|
print_status_and_exit(1)
|
||||||
|
|
||||||
|
|
||||||
# Returns True if and only if we "Detected server failure" sending the zephyr.
|
# Returns True if and only if we "Detected server failure" sending the zephyr.
|
||||||
def send_zephyr(zwrite_args: List[str], content: str) -> bool:
|
def send_zephyr(zwrite_args: List[str], content: str) -> bool:
|
||||||
p = subprocess.Popen(zwrite_args, stdin=subprocess.PIPE,
|
p = subprocess.Popen(
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
zwrite_args,
|
||||||
universal_newlines=True)
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
universal_newlines=True,
|
||||||
|
)
|
||||||
stdout, stderr = p.communicate(input=content)
|
stdout, stderr = p.communicate(input=content)
|
||||||
if p.returncode != 0:
|
if p.returncode != 0:
|
||||||
if "Detected server failure while receiving acknowledgement for" in stdout:
|
if "Detected server failure while receiving acknowledgement for" in stdout:
|
||||||
|
@ -116,6 +112,7 @@ def send_zephyr(zwrite_args: List[str], content: str) -> bool:
|
||||||
print_status_and_exit(1)
|
print_status_and_exit(1)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Subscribe to Zulip
|
# Subscribe to Zulip
|
||||||
try:
|
try:
|
||||||
res = zulip_client.register(event_types=["message"])
|
res = zulip_client.register(event_types=["message"])
|
||||||
|
@ -164,6 +161,8 @@ if not actually_subscribed:
|
||||||
# Prepare keys
|
# Prepare keys
|
||||||
zhkeys = {} # type: Dict[str, Tuple[str, str]]
|
zhkeys = {} # type: Dict[str, Tuple[str, str]]
|
||||||
hzkeys = {} # type: Dict[str, Tuple[str, str]]
|
hzkeys = {} # type: Dict[str, Tuple[str, str]]
|
||||||
|
|
||||||
|
|
||||||
def gen_key(key_dict: Dict[str, Tuple[str, str]]) -> str:
|
def gen_key(key_dict: Dict[str, Tuple[str, str]]) -> str:
|
||||||
bits = str(random.getrandbits(32))
|
bits = str(random.getrandbits(32))
|
||||||
while bits in key_dict:
|
while bits in key_dict:
|
||||||
|
@ -171,10 +170,12 @@ def gen_key(key_dict: Dict[str, Tuple[str, str]]) -> str:
|
||||||
bits = str(random.getrandbits(32))
|
bits = str(random.getrandbits(32))
|
||||||
return bits
|
return bits
|
||||||
|
|
||||||
|
|
||||||
def gen_keys(key_dict: Dict[str, Tuple[str, str]]) -> None:
|
def gen_keys(key_dict: Dict[str, Tuple[str, str]]) -> None:
|
||||||
for (stream, test) in test_streams:
|
for (stream, test) in test_streams:
|
||||||
key_dict[gen_key(key_dict)] = (stream, test)
|
key_dict[gen_key(key_dict)] = (stream, test)
|
||||||
|
|
||||||
|
|
||||||
gen_keys(zhkeys)
|
gen_keys(zhkeys)
|
||||||
gen_keys(hzkeys)
|
gen_keys(hzkeys)
|
||||||
|
|
||||||
|
@ -196,6 +197,7 @@ def receive_zephyrs() -> None:
|
||||||
continue
|
continue
|
||||||
notices.append(notice)
|
notices.append(notice)
|
||||||
|
|
||||||
|
|
||||||
logger.info("Starting sending messages!")
|
logger.info("Starting sending messages!")
|
||||||
# Send zephyrs
|
# Send zephyrs
|
||||||
zsig = "Timothy Good Abbott"
|
zsig = "Timothy Good Abbott"
|
||||||
|
@ -212,12 +214,15 @@ for key, (stream, test) in zhkeys.items():
|
||||||
zhkeys[new_key] = value
|
zhkeys[new_key] = value
|
||||||
server_failure_again = send_zephyr(zwrite_args, str(new_key))
|
server_failure_again = send_zephyr(zwrite_args, str(new_key))
|
||||||
if server_failure_again:
|
if server_failure_again:
|
||||||
logging.error("Zephyr server failure twice in a row on keys %s and %s! Aborting." %
|
logging.error(
|
||||||
(key, new_key))
|
"Zephyr server failure twice in a row on keys %s and %s! Aborting."
|
||||||
|
% (key, new_key)
|
||||||
|
)
|
||||||
print_status_and_exit(1)
|
print_status_and_exit(1)
|
||||||
else:
|
else:
|
||||||
logging.warning("Replaced key %s with %s due to Zephyr server failure." %
|
logging.warning(
|
||||||
(key, new_key))
|
"Replaced key %s with %s due to Zephyr server failure." % (key, new_key)
|
||||||
|
)
|
||||||
receive_zephyrs()
|
receive_zephyrs()
|
||||||
|
|
||||||
receive_zephyrs()
|
receive_zephyrs()
|
||||||
|
@ -226,18 +231,22 @@ logger.info("Sent Zephyr messages!")
|
||||||
# Send Zulips
|
# Send Zulips
|
||||||
for key, (stream, test) in hzkeys.items():
|
for key, (stream, test) in hzkeys.items():
|
||||||
if stream == "message":
|
if stream == "message":
|
||||||
send_zulip({
|
send_zulip(
|
||||||
"type": "private",
|
{
|
||||||
"content": str(key),
|
"type": "private",
|
||||||
"to": zulip_client.email,
|
"content": str(key),
|
||||||
})
|
"to": zulip_client.email,
|
||||||
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
send_zulip({
|
send_zulip(
|
||||||
"type": "stream",
|
{
|
||||||
"subject": "test",
|
"type": "stream",
|
||||||
"content": str(key),
|
"subject": "test",
|
||||||
"to": stream,
|
"content": str(key),
|
||||||
})
|
"to": stream,
|
||||||
|
}
|
||||||
|
)
|
||||||
receive_zephyrs()
|
receive_zephyrs()
|
||||||
|
|
||||||
logger.info("Sent Zulip messages!")
|
logger.info("Sent Zulip messages!")
|
||||||
|
@ -265,6 +274,8 @@ receive_zephyrs()
|
||||||
logger.info("Finished receiving Zephyr messages!")
|
logger.info("Finished receiving Zephyr messages!")
|
||||||
|
|
||||||
all_keys = set(list(zhkeys.keys()) + list(hzkeys.keys()))
|
all_keys = set(list(zhkeys.keys()) + list(hzkeys.keys()))
|
||||||
|
|
||||||
|
|
||||||
def process_keys(content_list: List[str]) -> Tuple[Dict[str, int], Set[str], Set[str], bool, bool]:
|
def process_keys(content_list: List[str]) -> Tuple[Dict[str, int], Set[str], Set[str], bool, bool]:
|
||||||
|
|
||||||
# Start by filtering out any keys that might have come from
|
# Start by filtering out any keys that might have come from
|
||||||
|
@ -281,6 +292,7 @@ def process_keys(content_list: List[str]) -> Tuple[Dict[str, int], Set[str], Set
|
||||||
success = all(val == 1 for val in key_counts.values())
|
success = all(val == 1 for val in key_counts.values())
|
||||||
return key_counts, z_missing, h_missing, duplicates, success
|
return key_counts, z_missing, h_missing, duplicates, success
|
||||||
|
|
||||||
|
|
||||||
# The h_foo variables are about the messages we _received_ in Zulip
|
# The h_foo variables are about the messages we _received_ in Zulip
|
||||||
# The z_foo variables are about the messages we _received_ in Zephyr
|
# The z_foo variables are about the messages we _received_ in Zephyr
|
||||||
h_contents = [message["content"] for message in messages]
|
h_contents = [message["content"] for message in messages]
|
||||||
|
@ -302,12 +314,16 @@ for key in all_keys:
|
||||||
continue
|
continue
|
||||||
if key in zhkeys:
|
if key in zhkeys:
|
||||||
(stream, test) = zhkeys[key]
|
(stream, test) = zhkeys[key]
|
||||||
logger.warning("%10s: z got %s, h got %s. Sent via Zephyr(%s): class %s" %
|
logger.warning(
|
||||||
(key, z_key_counts[key], h_key_counts[key], test, stream))
|
"%10s: z got %s, h got %s. Sent via Zephyr(%s): class %s"
|
||||||
|
% (key, z_key_counts[key], h_key_counts[key], test, stream)
|
||||||
|
)
|
||||||
if key in hzkeys:
|
if key in hzkeys:
|
||||||
(stream, test) = hzkeys[key]
|
(stream, test) = hzkeys[key]
|
||||||
logger.warning("%10s: z got %s. h got %s. Sent via Zulip(%s): class %s" %
|
logger.warning(
|
||||||
(key, z_key_counts[key], h_key_counts[key], test, stream))
|
"%10s: z got %s. h got %s. Sent via Zulip(%s): class %s"
|
||||||
|
% (key, z_key_counts[key], h_key_counts[key], test, stream)
|
||||||
|
)
|
||||||
logger.error("")
|
logger.error("")
|
||||||
logger.error("Summary of specific problems:")
|
logger.error("Summary of specific problems:")
|
||||||
|
|
||||||
|
@ -322,10 +338,14 @@ if z_duplicates:
|
||||||
|
|
||||||
if z_missing_z:
|
if z_missing_z:
|
||||||
logger.error("zephyr: Didn't receive all the Zephyrs we sent on the Zephyr end!")
|
logger.error("zephyr: Didn't receive all the Zephyrs we sent on the Zephyr end!")
|
||||||
logger.error("zephyr: This is probably an issue with check-mirroring sending or receiving Zephyrs.")
|
logger.error(
|
||||||
|
"zephyr: This is probably an issue with check-mirroring sending or receiving Zephyrs."
|
||||||
|
)
|
||||||
if h_missing_h:
|
if h_missing_h:
|
||||||
logger.error("zulip: Didn't receive all the Zulips we sent on the Zulip end!")
|
logger.error("zulip: Didn't receive all the Zulips we sent on the Zulip end!")
|
||||||
logger.error("zulip: This is probably an issue with check-mirroring sending or receiving Zulips.")
|
logger.error(
|
||||||
|
"zulip: This is probably an issue with check-mirroring sending or receiving Zulips."
|
||||||
|
)
|
||||||
if z_missing_h:
|
if z_missing_h:
|
||||||
logger.error("zephyr: Didn't receive all the Zulips we sent on the Zephyr end!")
|
logger.error("zephyr: Didn't receive all the Zulips we sent on the Zephyr end!")
|
||||||
if z_missing_h == h_missing_h:
|
if z_missing_h == h_missing_h:
|
||||||
|
|
|
@ -27,7 +27,9 @@ session_path = "/home/zulip/zephyr_sessions/%s" % (program_name,)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if "--forward-mail-zephyrs" in open(supervisor_path).read():
|
if "--forward-mail-zephyrs" in open(supervisor_path).read():
|
||||||
template_data = template_data.replace("--use-sessions", "--use-sessions --forward-mail-zephyrs")
|
template_data = template_data.replace(
|
||||||
|
"--use-sessions", "--use-sessions --forward-mail-zephyrs"
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
open(supervisor_path, "w").write(template_data.replace("USERNAME", short_user))
|
open(supervisor_path, "w").write(template_data.replace("USERNAME", short_user))
|
||||||
|
|
|
@ -17,9 +17,22 @@ def write_public_streams() -> None:
|
||||||
# Zephyr class names are canonicalized by first applying NFKC
|
# Zephyr class names are canonicalized by first applying NFKC
|
||||||
# normalization and then lower-casing server-side
|
# normalization and then lower-casing server-side
|
||||||
canonical_cls = unicodedata.normalize("NFKC", stream_name).lower()
|
canonical_cls = unicodedata.normalize("NFKC", stream_name).lower()
|
||||||
if canonical_cls in ['security', 'login', 'network', 'ops', 'user_locate',
|
if canonical_cls in [
|
||||||
'mit', 'moof', 'wsmonitor', 'wg_ctl', 'winlogger',
|
'security',
|
||||||
'hm_ctl', 'hm_stat', 'zephyr_admin', 'zephyr_ctl']:
|
'login',
|
||||||
|
'network',
|
||||||
|
'ops',
|
||||||
|
'user_locate',
|
||||||
|
'mit',
|
||||||
|
'moof',
|
||||||
|
'wsmonitor',
|
||||||
|
'wg_ctl',
|
||||||
|
'winlogger',
|
||||||
|
'hm_ctl',
|
||||||
|
'hm_stat',
|
||||||
|
'zephyr_admin',
|
||||||
|
'zephyr_ctl',
|
||||||
|
]:
|
||||||
# These zephyr classes cannot be subscribed to by us, due
|
# These zephyr classes cannot be subscribed to by us, due
|
||||||
# to MIT's Zephyr access control settings
|
# to MIT's Zephyr access control settings
|
||||||
continue
|
continue
|
||||||
|
@ -30,6 +43,7 @@ def write_public_streams() -> None:
|
||||||
f.write(json.dumps(list(public_streams)) + "\n")
|
f.write(json.dumps(list(public_streams)) + "\n")
|
||||||
os.rename("/home/zulip/public_streams.tmp", "/home/zulip/public_streams")
|
os.rename("/home/zulip/public_streams.tmp", "/home/zulip/public_streams")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
log_file = "/home/zulip/sync_public_streams.log"
|
log_file = "/home/zulip/sync_public_streams.log"
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -83,9 +97,7 @@ if __name__ == "__main__":
|
||||||
last_event_id = max(last_event_id, event["id"])
|
last_event_id = max(last_event_id, event["id"])
|
||||||
if event["type"] == "stream":
|
if event["type"] == "stream":
|
||||||
if event["op"] == "create":
|
if event["op"] == "create":
|
||||||
stream_names.update(
|
stream_names.update(stream["name"] for stream in event["streams"])
|
||||||
stream["name"] for stream in event["streams"]
|
|
||||||
)
|
|
||||||
write_public_streams()
|
write_public_streams()
|
||||||
elif event["op"] == "delete":
|
elif event["op"] == "delete":
|
||||||
stream_names.difference_update(
|
stream_names.difference_update(
|
||||||
|
|
|
@ -19,6 +19,7 @@ def die(signal: int, frame: FrameType) -> None:
|
||||||
# We actually want to exit, so run os._exit (so as not to be caught and restarted)
|
# We actually want to exit, so run os._exit (so as not to be caught and restarted)
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, die)
|
signal.signal(signal.SIGINT, die)
|
||||||
|
|
||||||
from zulip import RandomExponentialBackoff
|
from zulip import RandomExponentialBackoff
|
||||||
|
@ -36,12 +37,14 @@ if options.forward_class_messages and not options.noshard:
|
||||||
if options.on_startup_command is not None:
|
if options.on_startup_command is not None:
|
||||||
subprocess.call([options.on_startup_command])
|
subprocess.call([options.on_startup_command])
|
||||||
from zerver.lib.parallel import run_parallel
|
from zerver.lib.parallel import run_parallel
|
||||||
|
|
||||||
print("Starting parallel zephyr class mirroring bot")
|
print("Starting parallel zephyr class mirroring bot")
|
||||||
jobs = list("0123456789abcdef")
|
jobs = list("0123456789abcdef")
|
||||||
|
|
||||||
def run_job(shard: str) -> int:
|
def run_job(shard: str) -> int:
|
||||||
subprocess.call(args + ["--shard=%s" % (shard,)])
|
subprocess.call(args + ["--shard=%s" % (shard,)])
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
for (status, job) in run_parallel(run_job, jobs, threads=16):
|
for (status, job) in run_parallel(run_job, jobs, threads=16):
|
||||||
print("A mirroring shard died!")
|
print("A mirroring shard died!")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
|
@ -22,12 +22,16 @@ from zulip import RandomExponentialBackoff
|
||||||
|
|
||||||
DEFAULT_SITE = "https://api.zulip.com"
|
DEFAULT_SITE = "https://api.zulip.com"
|
||||||
|
|
||||||
|
|
||||||
class States:
|
class States:
|
||||||
Startup, ZulipToZephyr, ZephyrToZulip, ChildSending = list(range(4))
|
Startup, ZulipToZephyr, ZephyrToZulip, ChildSending = list(range(4))
|
||||||
|
|
||||||
|
|
||||||
CURRENT_STATE = States.Startup
|
CURRENT_STATE = States.Startup
|
||||||
|
|
||||||
logger: logging.Logger
|
logger: logging.Logger
|
||||||
|
|
||||||
|
|
||||||
def to_zulip_username(zephyr_username: str) -> str:
|
def to_zulip_username(zephyr_username: str) -> str:
|
||||||
if "@" in zephyr_username:
|
if "@" in zephyr_username:
|
||||||
(user, realm) = zephyr_username.split("@")
|
(user, realm) = zephyr_username.split("@")
|
||||||
|
@ -40,6 +44,7 @@ def to_zulip_username(zephyr_username: str) -> str:
|
||||||
return user.lower() + "@mit.edu"
|
return user.lower() + "@mit.edu"
|
||||||
return user.lower() + "|" + realm.upper() + "@mit.edu"
|
return user.lower() + "|" + realm.upper() + "@mit.edu"
|
||||||
|
|
||||||
|
|
||||||
def to_zephyr_username(zulip_username: str) -> str:
|
def to_zephyr_username(zulip_username: str) -> str:
|
||||||
(user, realm) = zulip_username.split("@")
|
(user, realm) = zulip_username.split("@")
|
||||||
if "|" not in user:
|
if "|" not in user:
|
||||||
|
@ -52,6 +57,7 @@ def to_zephyr_username(zulip_username: str) -> str:
|
||||||
raise Exception("Could not parse Zephyr realm for cross-realm user %s" % (zulip_username,))
|
raise Exception("Could not parse Zephyr realm for cross-realm user %s" % (zulip_username,))
|
||||||
return match_user.group(1).lower() + "@" + match_user.group(2).upper()
|
return match_user.group(1).lower() + "@" + match_user.group(2).upper()
|
||||||
|
|
||||||
|
|
||||||
# Checks whether the pair of adjacent lines would have been
|
# Checks whether the pair of adjacent lines would have been
|
||||||
# linewrapped together, had they been intended to be parts of the same
|
# linewrapped together, had they been intended to be parts of the same
|
||||||
# paragraph. Our check is whether if you move the first word on the
|
# paragraph. Our check is whether if you move the first word on the
|
||||||
|
@ -70,6 +76,7 @@ def different_paragraph(line: str, next_line: str) -> bool:
|
||||||
or len(line) < len(words[0])
|
or len(line) < len(words[0])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Linewrapping algorithm based on:
|
# Linewrapping algorithm based on:
|
||||||
# http://gcbenison.wordpress.com/2011/07/03/a-program-to-intelligently-remove-carriage-returns-so-you-can-paste-text-without-having-it-look-awful/ #ignorelongline
|
# http://gcbenison.wordpress.com/2011/07/03/a-program-to-intelligently-remove-carriage-returns-so-you-can-paste-text-without-having-it-look-awful/ #ignorelongline
|
||||||
def unwrap_lines(body: str) -> str:
|
def unwrap_lines(body: str) -> str:
|
||||||
|
@ -78,9 +85,8 @@ def unwrap_lines(body: str) -> str:
|
||||||
previous_line = lines[0]
|
previous_line = lines[0]
|
||||||
for line in lines[1:]:
|
for line in lines[1:]:
|
||||||
line = line.rstrip()
|
line = line.rstrip()
|
||||||
if (
|
if re.match(r'^\W', line, flags=re.UNICODE) and re.match(
|
||||||
re.match(r'^\W', line, flags=re.UNICODE)
|
r'^\W', previous_line, flags=re.UNICODE
|
||||||
and re.match(r'^\W', previous_line, flags=re.UNICODE)
|
|
||||||
):
|
):
|
||||||
result += previous_line + "\n"
|
result += previous_line + "\n"
|
||||||
elif (
|
elif (
|
||||||
|
@ -99,6 +105,7 @@ def unwrap_lines(body: str) -> str:
|
||||||
result += previous_line
|
result += previous_line
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class ZephyrDict(TypedDict, total=False):
|
class ZephyrDict(TypedDict, total=False):
|
||||||
type: Literal["private", "stream"]
|
type: Literal["private", "stream"]
|
||||||
time: str
|
time: str
|
||||||
|
@ -109,6 +116,7 @@ class ZephyrDict(TypedDict, total=False):
|
||||||
content: str
|
content: str
|
||||||
zsig: str
|
zsig: str
|
||||||
|
|
||||||
|
|
||||||
def send_zulip(zeph: ZephyrDict) -> Dict[str, Any]:
|
def send_zulip(zeph: ZephyrDict) -> Dict[str, Any]:
|
||||||
message: Dict[str, Any]
|
message: Dict[str, Any]
|
||||||
message = {}
|
message = {}
|
||||||
|
@ -142,15 +150,20 @@ def send_zulip(zeph: ZephyrDict) -> Dict[str, Any]:
|
||||||
|
|
||||||
return zulip_client.send_message(message)
|
return zulip_client.send_message(message)
|
||||||
|
|
||||||
|
|
||||||
def send_error_zulip(error_msg: str) -> None:
|
def send_error_zulip(error_msg: str) -> None:
|
||||||
message = {"type": "private",
|
message = {
|
||||||
"sender": zulip_account_email,
|
"type": "private",
|
||||||
"to": zulip_account_email,
|
"sender": zulip_account_email,
|
||||||
"content": error_msg,
|
"to": zulip_account_email,
|
||||||
}
|
"content": error_msg,
|
||||||
|
}
|
||||||
zulip_client.send_message(message)
|
zulip_client.send_message(message)
|
||||||
|
|
||||||
|
|
||||||
current_zephyr_subs = set()
|
current_zephyr_subs = set()
|
||||||
|
|
||||||
|
|
||||||
def zephyr_bulk_subscribe(subs: List[Tuple[str, str, str]]) -> None:
|
def zephyr_bulk_subscribe(subs: List[Tuple[str, str, str]]) -> None:
|
||||||
try:
|
try:
|
||||||
zephyr._z.subAll(subs)
|
zephyr._z.subAll(subs)
|
||||||
|
@ -186,6 +199,7 @@ def zephyr_bulk_subscribe(subs: List[Tuple[str, str, str]]) -> None:
|
||||||
else:
|
else:
|
||||||
current_zephyr_subs.add(cls)
|
current_zephyr_subs.add(cls)
|
||||||
|
|
||||||
|
|
||||||
def update_subscriptions() -> None:
|
def update_subscriptions() -> None:
|
||||||
try:
|
try:
|
||||||
f = open(options.stream_file_path)
|
f = open(options.stream_file_path)
|
||||||
|
@ -198,10 +212,9 @@ def update_subscriptions() -> None:
|
||||||
classes_to_subscribe = set()
|
classes_to_subscribe = set()
|
||||||
for stream in public_streams:
|
for stream in public_streams:
|
||||||
zephyr_class = stream
|
zephyr_class = stream
|
||||||
if (
|
if options.shard is not None and not hashlib.sha1(
|
||||||
options.shard is not None
|
zephyr_class.encode("utf-8")
|
||||||
and not hashlib.sha1(zephyr_class.encode("utf-8")).hexdigest().startswith(options.shard)
|
).hexdigest().startswith(options.shard):
|
||||||
):
|
|
||||||
# This stream is being handled by a different zephyr_mirror job.
|
# This stream is being handled by a different zephyr_mirror job.
|
||||||
continue
|
continue
|
||||||
if zephyr_class in current_zephyr_subs:
|
if zephyr_class in current_zephyr_subs:
|
||||||
|
@ -211,6 +224,7 @@ def update_subscriptions() -> None:
|
||||||
if len(classes_to_subscribe) > 0:
|
if len(classes_to_subscribe) > 0:
|
||||||
zephyr_bulk_subscribe(list(classes_to_subscribe))
|
zephyr_bulk_subscribe(list(classes_to_subscribe))
|
||||||
|
|
||||||
|
|
||||||
def maybe_kill_child() -> None:
|
def maybe_kill_child() -> None:
|
||||||
try:
|
try:
|
||||||
if child_pid is not None:
|
if child_pid is not None:
|
||||||
|
@ -219,10 +233,14 @@ def maybe_kill_child() -> None:
|
||||||
# We don't care if the child process no longer exists, so just log the error
|
# We don't care if the child process no longer exists, so just log the error
|
||||||
logger.exception("")
|
logger.exception("")
|
||||||
|
|
||||||
|
|
||||||
def maybe_restart_mirroring_script() -> None:
|
def maybe_restart_mirroring_script() -> None:
|
||||||
if os.stat(os.path.join(options.stamp_path, "stamps", "restart_stamp")).st_mtime > start_time or (
|
if os.stat(
|
||||||
|
os.path.join(options.stamp_path, "stamps", "restart_stamp")
|
||||||
|
).st_mtime > start_time or (
|
||||||
(options.user == "tabbott" or options.user == "tabbott/extra")
|
(options.user == "tabbott" or options.user == "tabbott/extra")
|
||||||
and os.stat(os.path.join(options.stamp_path, "stamps", "tabbott_stamp")).st_mtime > start_time
|
and os.stat(os.path.join(options.stamp_path, "stamps", "tabbott_stamp")).st_mtime
|
||||||
|
> start_time
|
||||||
):
|
):
|
||||||
logger.warning("")
|
logger.warning("")
|
||||||
logger.warning("zephyr mirroring script has been updated; restarting...")
|
logger.warning("zephyr mirroring script has been updated; restarting...")
|
||||||
|
@ -244,6 +262,7 @@ def maybe_restart_mirroring_script() -> None:
|
||||||
backoff.fail()
|
backoff.fail()
|
||||||
raise Exception("Failed to reload too many times, aborting!")
|
raise Exception("Failed to reload too many times, aborting!")
|
||||||
|
|
||||||
|
|
||||||
def process_loop(log: Optional[IO[str]]) -> NoReturn:
|
def process_loop(log: Optional[IO[str]]) -> NoReturn:
|
||||||
restart_check_count = 0
|
restart_check_count = 0
|
||||||
last_check_time = time.time()
|
last_check_time = time.time()
|
||||||
|
@ -287,6 +306,7 @@ def process_loop(log: Optional[IO[str]]) -> NoReturn:
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error updating subscriptions from Zulip:")
|
logger.exception("Error updating subscriptions from Zulip:")
|
||||||
|
|
||||||
|
|
||||||
def parse_zephyr_body(zephyr_data: str, notice_format: str) -> Tuple[str, str]:
|
def parse_zephyr_body(zephyr_data: str, notice_format: str) -> Tuple[str, str]:
|
||||||
try:
|
try:
|
||||||
(zsig, body) = zephyr_data.split("\x00", 1)
|
(zsig, body) = zephyr_data.split("\x00", 1)
|
||||||
|
@ -298,13 +318,19 @@ def parse_zephyr_body(zephyr_data: str, notice_format: str) -> Tuple[str, str]:
|
||||||
fields = body.split('\x00')
|
fields = body.split('\x00')
|
||||||
if len(fields) == 5:
|
if len(fields) == 5:
|
||||||
body = 'New transaction [%s] entered in %s\nFrom: %s (%s)\nSubject: %s' % (
|
body = 'New transaction [%s] entered in %s\nFrom: %s (%s)\nSubject: %s' % (
|
||||||
fields[0], fields[1], fields[2], fields[4], fields[3])
|
fields[0],
|
||||||
|
fields[1],
|
||||||
|
fields[2],
|
||||||
|
fields[4],
|
||||||
|
fields[3],
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
(zsig, body) = ("", zephyr_data)
|
(zsig, body) = ("", zephyr_data)
|
||||||
# Clean body of any null characters, since they're invalid in our protocol.
|
# Clean body of any null characters, since they're invalid in our protocol.
|
||||||
body = body.replace('\x00', '')
|
body = body.replace('\x00', '')
|
||||||
return (zsig, body)
|
return (zsig, body)
|
||||||
|
|
||||||
|
|
||||||
def parse_crypt_table(zephyr_class: str, instance: str) -> Optional[str]:
|
def parse_crypt_table(zephyr_class: str, instance: str) -> Optional[str]:
|
||||||
try:
|
try:
|
||||||
crypt_table = open(os.path.join(os.environ["HOME"], ".crypt-table"))
|
crypt_table = open(os.path.join(os.environ["HOME"], ".crypt-table"))
|
||||||
|
@ -315,17 +341,23 @@ def parse_crypt_table(zephyr_class: str, instance: str) -> Optional[str]:
|
||||||
if line.strip() == "":
|
if line.strip() == "":
|
||||||
# Ignore blank lines
|
# Ignore blank lines
|
||||||
continue
|
continue
|
||||||
match = re.match(r"^crypt-(?P<class>\S+):\s+((?P<algorithm>(AES|DES)):\s+)?(?P<keypath>\S+)$", line)
|
match = re.match(
|
||||||
|
r"^crypt-(?P<class>\S+):\s+((?P<algorithm>(AES|DES)):\s+)?(?P<keypath>\S+)$", line
|
||||||
|
)
|
||||||
if match is None:
|
if match is None:
|
||||||
# Malformed crypt_table line
|
# Malformed crypt_table line
|
||||||
logger.debug("Invalid crypt_table line!")
|
logger.debug("Invalid crypt_table line!")
|
||||||
continue
|
continue
|
||||||
groups = match.groupdict()
|
groups = match.groupdict()
|
||||||
if groups['class'].lower() == zephyr_class and 'keypath' in groups and \
|
if (
|
||||||
groups.get("algorithm") == "AES":
|
groups['class'].lower() == zephyr_class
|
||||||
|
and 'keypath' in groups
|
||||||
|
and groups.get("algorithm") == "AES"
|
||||||
|
):
|
||||||
return groups["keypath"]
|
return groups["keypath"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def decrypt_zephyr(zephyr_class: str, instance: str, body: str) -> str:
|
def decrypt_zephyr(zephyr_class: str, instance: str, body: str) -> str:
|
||||||
keypath = parse_crypt_table(zephyr_class, instance)
|
keypath = parse_crypt_table(zephyr_class, instance)
|
||||||
if keypath is None:
|
if keypath is None:
|
||||||
|
@ -337,27 +369,32 @@ def decrypt_zephyr(zephyr_class: str, instance: str, body: str) -> str:
|
||||||
signal.signal(signal.SIGCHLD, signal.SIG_DFL)
|
signal.signal(signal.SIGCHLD, signal.SIG_DFL)
|
||||||
|
|
||||||
# decrypt the message!
|
# decrypt the message!
|
||||||
p = subprocess.Popen(["gpg",
|
p = subprocess.Popen(
|
||||||
"--decrypt",
|
[
|
||||||
"--no-options",
|
"gpg",
|
||||||
"--no-default-keyring",
|
"--decrypt",
|
||||||
"--keyring=/dev/null",
|
"--no-options",
|
||||||
"--secret-keyring=/dev/null",
|
"--no-default-keyring",
|
||||||
"--batch",
|
"--keyring=/dev/null",
|
||||||
"--quiet",
|
"--secret-keyring=/dev/null",
|
||||||
"--no-use-agent",
|
"--batch",
|
||||||
"--passphrase-file",
|
"--quiet",
|
||||||
keypath],
|
"--no-use-agent",
|
||||||
stdin=subprocess.PIPE,
|
"--passphrase-file",
|
||||||
stdout=subprocess.PIPE,
|
keypath,
|
||||||
stderr=subprocess.PIPE,
|
],
|
||||||
universal_newlines=True,
|
stdin=subprocess.PIPE,
|
||||||
errors="replace")
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
universal_newlines=True,
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
decrypted, _ = p.communicate(input=body)
|
decrypted, _ = p.communicate(input=body)
|
||||||
# Restore our ignoring signals
|
# Restore our ignoring signals
|
||||||
signal.signal(signal.SIGCHLD, signal.SIG_IGN)
|
signal.signal(signal.SIGCHLD, signal.SIG_IGN)
|
||||||
return decrypted
|
return decrypted
|
||||||
|
|
||||||
|
|
||||||
def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
|
def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
|
||||||
assert notice.sender is not None
|
assert notice.sender is not None
|
||||||
(zsig, body) = parse_zephyr_body(notice.message, notice.format)
|
(zsig, body) = parse_zephyr_body(notice.message, notice.format)
|
||||||
|
@ -382,8 +419,7 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
|
||||||
if is_personal and not options.forward_personals:
|
if is_personal and not options.forward_personals:
|
||||||
return
|
return
|
||||||
if (zephyr_class not in current_zephyr_subs) and not is_personal:
|
if (zephyr_class not in current_zephyr_subs) and not is_personal:
|
||||||
logger.debug("Skipping ... %s/%s/%s" %
|
logger.debug("Skipping ... %s/%s/%s" % (zephyr_class, notice.instance, is_personal))
|
||||||
(zephyr_class, notice.instance, is_personal))
|
|
||||||
return
|
return
|
||||||
if notice.format.startswith("Zephyr error: See") or notice.format.endswith("@(@color(blue))"):
|
if notice.format.startswith("Zephyr error: See") or notice.format.endswith("@(@color(blue))"):
|
||||||
logger.debug("Skipping message we got from Zulip!")
|
logger.debug("Skipping message we got from Zulip!")
|
||||||
|
@ -401,20 +437,27 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
|
||||||
if body.startswith("CC:"):
|
if body.startswith("CC:"):
|
||||||
is_huddle = True
|
is_huddle = True
|
||||||
# Map "CC: user1 user2" => "user1@mit.edu, user2@mit.edu"
|
# Map "CC: user1 user2" => "user1@mit.edu, user2@mit.edu"
|
||||||
huddle_recipients = [to_zulip_username(x.strip()) for x in
|
huddle_recipients = [
|
||||||
body.split("\n")[0][4:].split()]
|
to_zulip_username(x.strip()) for x in body.split("\n")[0][4:].split()
|
||||||
|
]
|
||||||
if notice.sender not in huddle_recipients:
|
if notice.sender not in huddle_recipients:
|
||||||
huddle_recipients.append(to_zulip_username(notice.sender))
|
huddle_recipients.append(to_zulip_username(notice.sender))
|
||||||
body = body.split("\n", 1)[1]
|
body = body.split("\n", 1)[1]
|
||||||
|
|
||||||
if options.forward_class_messages and notice.opcode is not None and notice.opcode.lower() == "crypt":
|
if (
|
||||||
|
options.forward_class_messages
|
||||||
|
and notice.opcode is not None
|
||||||
|
and notice.opcode.lower() == "crypt"
|
||||||
|
):
|
||||||
body = decrypt_zephyr(zephyr_class, notice.instance.lower(), body)
|
body = decrypt_zephyr(zephyr_class, notice.instance.lower(), body)
|
||||||
|
|
||||||
zeph: ZephyrDict
|
zeph: ZephyrDict
|
||||||
zeph = {'time': str(notice.time),
|
zeph = {
|
||||||
'sender': notice.sender,
|
'time': str(notice.time),
|
||||||
'zsig': zsig, # logged here but not used by app
|
'sender': notice.sender,
|
||||||
'content': body}
|
'zsig': zsig, # logged here but not used by app
|
||||||
|
'content': body,
|
||||||
|
}
|
||||||
if is_huddle:
|
if is_huddle:
|
||||||
zeph['type'] = 'private'
|
zeph['type'] = 'private'
|
||||||
zeph['recipient'] = huddle_recipients
|
zeph['recipient'] = huddle_recipients
|
||||||
|
@ -442,8 +485,9 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
|
||||||
heading = ""
|
heading = ""
|
||||||
zeph["content"] = heading + zeph["content"]
|
zeph["content"] = heading + zeph["content"]
|
||||||
|
|
||||||
logger.info("Received a message on %s/%s from %s..." %
|
logger.info(
|
||||||
(zephyr_class, notice.instance, notice.sender))
|
"Received a message on %s/%s from %s..." % (zephyr_class, notice.instance, notice.sender)
|
||||||
|
)
|
||||||
if log is not None:
|
if log is not None:
|
||||||
log.write(json.dumps(zeph) + '\n')
|
log.write(json.dumps(zeph) + '\n')
|
||||||
log.flush()
|
log.flush()
|
||||||
|
@ -461,11 +505,13 @@ def process_notice(notice: "zephyr.ZNotice", log: Optional[IO[str]]) -> None:
|
||||||
finally:
|
finally:
|
||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
|
|
||||||
def quit_failed_initialization(message: str) -> str:
|
def quit_failed_initialization(message: str) -> str:
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
maybe_kill_child()
|
maybe_kill_child()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def zephyr_init_autoretry() -> None:
|
def zephyr_init_autoretry() -> None:
|
||||||
backoff = zulip.RandomExponentialBackoff()
|
backoff = zulip.RandomExponentialBackoff()
|
||||||
while backoff.keep_going():
|
while backoff.keep_going():
|
||||||
|
@ -481,6 +527,7 @@ def zephyr_init_autoretry() -> None:
|
||||||
|
|
||||||
quit_failed_initialization("Could not initialize Zephyr library, quitting!")
|
quit_failed_initialization("Could not initialize Zephyr library, quitting!")
|
||||||
|
|
||||||
|
|
||||||
def zephyr_load_session_autoretry(session_path: str) -> None:
|
def zephyr_load_session_autoretry(session_path: str) -> None:
|
||||||
backoff = zulip.RandomExponentialBackoff()
|
backoff = zulip.RandomExponentialBackoff()
|
||||||
while backoff.keep_going():
|
while backoff.keep_going():
|
||||||
|
@ -497,6 +544,7 @@ def zephyr_load_session_autoretry(session_path: str) -> None:
|
||||||
|
|
||||||
quit_failed_initialization("Could not load saved Zephyr session, quitting!")
|
quit_failed_initialization("Could not load saved Zephyr session, quitting!")
|
||||||
|
|
||||||
|
|
||||||
def zephyr_subscribe_autoretry(sub: Tuple[str, str, str]) -> None:
|
def zephyr_subscribe_autoretry(sub: Tuple[str, str, str]) -> None:
|
||||||
backoff = zulip.RandomExponentialBackoff()
|
backoff = zulip.RandomExponentialBackoff()
|
||||||
while backoff.keep_going():
|
while backoff.keep_going():
|
||||||
|
@ -512,6 +560,7 @@ def zephyr_subscribe_autoretry(sub: Tuple[str, str, str]) -> None:
|
||||||
|
|
||||||
quit_failed_initialization("Could not subscribe to personals, quitting!")
|
quit_failed_initialization("Could not subscribe to personals, quitting!")
|
||||||
|
|
||||||
|
|
||||||
def zephyr_to_zulip(options: optparse.Values) -> None:
|
def zephyr_to_zulip(options: optparse.Values) -> None:
|
||||||
if options.use_sessions and os.path.exists(options.session_path):
|
if options.use_sessions and os.path.exists(options.session_path):
|
||||||
logger.info("Loading old session")
|
logger.info("Loading old session")
|
||||||
|
@ -542,9 +591,10 @@ def zephyr_to_zulip(options: optparse.Values) -> None:
|
||||||
zeph["stream"] = zeph["class"]
|
zeph["stream"] = zeph["class"]
|
||||||
if "instance" in zeph:
|
if "instance" in zeph:
|
||||||
zeph["subject"] = zeph["instance"]
|
zeph["subject"] = zeph["instance"]
|
||||||
logger.info("sending saved message to %s from %s..." %
|
logger.info(
|
||||||
(zeph.get('stream', zeph.get('recipient')),
|
"sending saved message to %s from %s..."
|
||||||
zeph['sender']))
|
% (zeph.get('stream', zeph.get('recipient')), zeph['sender'])
|
||||||
|
)
|
||||||
send_zulip(zeph)
|
send_zulip(zeph)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Could not send saved zephyr:")
|
logger.exception("Could not send saved zephyr:")
|
||||||
|
@ -558,55 +608,75 @@ def zephyr_to_zulip(options: optparse.Values) -> None:
|
||||||
else:
|
else:
|
||||||
process_loop(None)
|
process_loop(None)
|
||||||
|
|
||||||
|
|
||||||
def send_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]:
|
def send_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]:
|
||||||
p = subprocess.Popen(zwrite_args, stdin=subprocess.PIPE,
|
p = subprocess.Popen(
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
zwrite_args,
|
||||||
universal_newlines=True)
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
universal_newlines=True,
|
||||||
|
)
|
||||||
stdout, stderr = p.communicate(input=content)
|
stdout, stderr = p.communicate(input=content)
|
||||||
if p.returncode:
|
if p.returncode:
|
||||||
logger.error("zwrite command '%s' failed with return code %d:" % (
|
logger.error(
|
||||||
" ".join(zwrite_args), p.returncode,))
|
"zwrite command '%s' failed with return code %d:"
|
||||||
|
% (
|
||||||
|
" ".join(zwrite_args),
|
||||||
|
p.returncode,
|
||||||
|
)
|
||||||
|
)
|
||||||
if stdout:
|
if stdout:
|
||||||
logger.info("stdout: " + stdout)
|
logger.info("stdout: " + stdout)
|
||||||
elif stderr:
|
elif stderr:
|
||||||
logger.warning("zwrite command '%s' printed the following warning:" % (
|
logger.warning(
|
||||||
" ".join(zwrite_args),))
|
"zwrite command '%s' printed the following warning:" % (" ".join(zwrite_args),)
|
||||||
|
)
|
||||||
if stderr:
|
if stderr:
|
||||||
logger.warning("stderr: " + stderr)
|
logger.warning("stderr: " + stderr)
|
||||||
return (p.returncode, stderr)
|
return (p.returncode, stderr)
|
||||||
|
|
||||||
|
|
||||||
def send_authed_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]:
|
def send_authed_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]:
|
||||||
return send_zephyr(zwrite_args, content)
|
return send_zephyr(zwrite_args, content)
|
||||||
|
|
||||||
|
|
||||||
def send_unauthed_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]:
|
def send_unauthed_zephyr(zwrite_args: List[str], content: str) -> Tuple[int, str]:
|
||||||
return send_zephyr(zwrite_args + ["-d"], content)
|
return send_zephyr(zwrite_args + ["-d"], content)
|
||||||
|
|
||||||
|
|
||||||
def zcrypt_encrypt_content(zephyr_class: str, instance: str, content: str) -> Optional[str]:
|
def zcrypt_encrypt_content(zephyr_class: str, instance: str, content: str) -> Optional[str]:
|
||||||
keypath = parse_crypt_table(zephyr_class, instance)
|
keypath = parse_crypt_table(zephyr_class, instance)
|
||||||
if keypath is None:
|
if keypath is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# encrypt the message!
|
# encrypt the message!
|
||||||
p = subprocess.Popen(["gpg",
|
p = subprocess.Popen(
|
||||||
"--symmetric",
|
[
|
||||||
"--no-options",
|
"gpg",
|
||||||
"--no-default-keyring",
|
"--symmetric",
|
||||||
"--keyring=/dev/null",
|
"--no-options",
|
||||||
"--secret-keyring=/dev/null",
|
"--no-default-keyring",
|
||||||
"--batch",
|
"--keyring=/dev/null",
|
||||||
"--quiet",
|
"--secret-keyring=/dev/null",
|
||||||
"--no-use-agent",
|
"--batch",
|
||||||
"--armor",
|
"--quiet",
|
||||||
"--cipher-algo", "AES",
|
"--no-use-agent",
|
||||||
"--passphrase-file",
|
"--armor",
|
||||||
keypath],
|
"--cipher-algo",
|
||||||
stdin=subprocess.PIPE,
|
"AES",
|
||||||
stdout=subprocess.PIPE,
|
"--passphrase-file",
|
||||||
stderr=subprocess.PIPE,
|
keypath,
|
||||||
universal_newlines=True)
|
],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
universal_newlines=True,
|
||||||
|
)
|
||||||
encrypted, _ = p.communicate(input=content)
|
encrypted, _ = p.communicate(input=content)
|
||||||
return encrypted
|
return encrypted
|
||||||
|
|
||||||
|
|
||||||
def forward_to_zephyr(message: Dict[str, Any]) -> None:
|
def forward_to_zephyr(message: Dict[str, Any]) -> None:
|
||||||
# 'Any' can be of any type of text
|
# 'Any' can be of any type of text
|
||||||
support_heading = "Hi there! This is an automated message from Zulip."
|
support_heading = "Hi there! This is an automated message from Zulip."
|
||||||
|
@ -614,12 +684,20 @@ def forward_to_zephyr(message: Dict[str, Any]) -> None:
|
||||||
Feedback button or at support@zulip.com."""
|
Feedback button or at support@zulip.com."""
|
||||||
|
|
||||||
wrapper = textwrap.TextWrapper(break_long_words=False, break_on_hyphens=False)
|
wrapper = textwrap.TextWrapper(break_long_words=False, break_on_hyphens=False)
|
||||||
wrapped_content = "\n".join("\n".join(wrapper.wrap(line))
|
wrapped_content = "\n".join(
|
||||||
for line in message["content"].replace("@", "@@").split("\n"))
|
"\n".join(wrapper.wrap(line)) for line in message["content"].replace("@", "@@").split("\n")
|
||||||
|
)
|
||||||
|
|
||||||
zwrite_args = ["zwrite", "-n", "-s", message["sender_full_name"],
|
zwrite_args = [
|
||||||
"-F", "Zephyr error: See http://zephyr.1ts.org/wiki/df",
|
"zwrite",
|
||||||
"-x", "UTF-8"]
|
"-n",
|
||||||
|
"-s",
|
||||||
|
message["sender_full_name"],
|
||||||
|
"-F",
|
||||||
|
"Zephyr error: See http://zephyr.1ts.org/wiki/df",
|
||||||
|
"-x",
|
||||||
|
"UTF-8",
|
||||||
|
]
|
||||||
|
|
||||||
# Hack to make ctl's fake username setup work :)
|
# Hack to make ctl's fake username setup work :)
|
||||||
if message['type'] == "stream" and zulip_account_email == "ctl@mit.edu":
|
if message['type'] == "stream" and zulip_account_email == "ctl@mit.edu":
|
||||||
|
@ -634,9 +712,8 @@ Feedback button or at support@zulip.com."""
|
||||||
# Forward messages sent to '(instance "WHITESPACE")' back to the
|
# Forward messages sent to '(instance "WHITESPACE")' back to the
|
||||||
# appropriate WHITESPACE instance for bidirectional mirroring
|
# appropriate WHITESPACE instance for bidirectional mirroring
|
||||||
instance = match_whitespace_instance.group(1)
|
instance = match_whitespace_instance.group(1)
|
||||||
elif (
|
elif instance == "instance %s" % (zephyr_class,) or instance == "test instance %s" % (
|
||||||
instance == "instance %s" % (zephyr_class,)
|
zephyr_class,
|
||||||
or instance == "test instance %s" % (zephyr_class,)
|
|
||||||
):
|
):
|
||||||
# Forward messages to e.g. -c -i white-magic back from the
|
# Forward messages to e.g. -c -i white-magic back from the
|
||||||
# place we forward them to
|
# place we forward them to
|
||||||
|
@ -663,15 +740,18 @@ Feedback button or at support@zulip.com."""
|
||||||
zwrite_args.extend(["-C"])
|
zwrite_args.extend(["-C"])
|
||||||
# We drop the @ATHENA.MIT.EDU here because otherwise the
|
# We drop the @ATHENA.MIT.EDU here because otherwise the
|
||||||
# "CC: user1 user2 ..." output will be unnecessarily verbose.
|
# "CC: user1 user2 ..." output will be unnecessarily verbose.
|
||||||
recipients = [to_zephyr_username(user["email"]).replace("@ATHENA.MIT.EDU", "")
|
recipients = [
|
||||||
for user in message["display_recipient"]]
|
to_zephyr_username(user["email"]).replace("@ATHENA.MIT.EDU", "")
|
||||||
|
for user in message["display_recipient"]
|
||||||
|
]
|
||||||
logger.info("Forwarding message to %s" % (recipients,))
|
logger.info("Forwarding message to %s" % (recipients,))
|
||||||
zwrite_args.extend(recipients)
|
zwrite_args.extend(recipients)
|
||||||
|
|
||||||
if message.get("invite_only_stream"):
|
if message.get("invite_only_stream"):
|
||||||
result = zcrypt_encrypt_content(zephyr_class, instance, wrapped_content)
|
result = zcrypt_encrypt_content(zephyr_class, instance, wrapped_content)
|
||||||
if result is None:
|
if result is None:
|
||||||
send_error_zulip("""%s
|
send_error_zulip(
|
||||||
|
"""%s
|
||||||
|
|
||||||
Your Zulip-Zephyr mirror bot was unable to forward that last message \
|
Your Zulip-Zephyr mirror bot was unable to forward that last message \
|
||||||
from Zulip to Zephyr because you were sending to a zcrypted Zephyr \
|
from Zulip to Zephyr because you were sending to a zcrypted Zephyr \
|
||||||
|
@ -679,7 +759,9 @@ class and your mirroring bot does not have access to the relevant \
|
||||||
key (perhaps because your AFS tokens expired). That means that while \
|
key (perhaps because your AFS tokens expired). That means that while \
|
||||||
Zulip users (like you) received it, Zephyr users did not.
|
Zulip users (like you) received it, Zephyr users did not.
|
||||||
|
|
||||||
%s""" % (support_heading, support_closing))
|
%s"""
|
||||||
|
% (support_heading, support_closing)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Proceed with sending a zcrypted message
|
# Proceed with sending a zcrypted message
|
||||||
|
@ -687,22 +769,24 @@ Zulip users (like you) received it, Zephyr users did not.
|
||||||
zwrite_args.extend(["-O", "crypt"])
|
zwrite_args.extend(["-O", "crypt"])
|
||||||
|
|
||||||
if options.test_mode:
|
if options.test_mode:
|
||||||
logger.debug("Would have forwarded: %s\n%s" %
|
logger.debug("Would have forwarded: %s\n%s" % (zwrite_args, wrapped_content))
|
||||||
(zwrite_args, wrapped_content))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
(code, stderr) = send_authed_zephyr(zwrite_args, wrapped_content)
|
(code, stderr) = send_authed_zephyr(zwrite_args, wrapped_content)
|
||||||
if code == 0 and stderr == "":
|
if code == 0 and stderr == "":
|
||||||
return
|
return
|
||||||
elif code == 0:
|
elif code == 0:
|
||||||
send_error_zulip("""%s
|
send_error_zulip(
|
||||||
|
"""%s
|
||||||
|
|
||||||
Your last message was successfully mirrored to zephyr, but zwrite \
|
Your last message was successfully mirrored to zephyr, but zwrite \
|
||||||
returned the following warning:
|
returned the following warning:
|
||||||
|
|
||||||
%s
|
%s
|
||||||
|
|
||||||
%s""" % (support_heading, stderr, support_closing))
|
%s"""
|
||||||
|
% (support_heading, stderr, support_closing)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
elif code != 0 and (
|
elif code != 0 and (
|
||||||
stderr.startswith("zwrite: Ticket expired while sending notice to ")
|
stderr.startswith("zwrite: Ticket expired while sending notice to ")
|
||||||
|
@ -714,7 +798,8 @@ returned the following warning:
|
||||||
if code == 0:
|
if code == 0:
|
||||||
if options.ignore_expired_tickets:
|
if options.ignore_expired_tickets:
|
||||||
return
|
return
|
||||||
send_error_zulip("""%s
|
send_error_zulip(
|
||||||
|
"""%s
|
||||||
|
|
||||||
Your last message was forwarded from Zulip to Zephyr unauthenticated, \
|
Your last message was forwarded from Zulip to Zephyr unauthenticated, \
|
||||||
because your Kerberos tickets have expired. It was sent successfully, \
|
because your Kerberos tickets have expired. It was sent successfully, \
|
||||||
|
@ -722,13 +807,16 @@ but please renew your Kerberos tickets in the screen session where you \
|
||||||
are running the Zulip-Zephyr mirroring bot, so we can send \
|
are running the Zulip-Zephyr mirroring bot, so we can send \
|
||||||
authenticated Zephyr messages for you again.
|
authenticated Zephyr messages for you again.
|
||||||
|
|
||||||
%s""" % (support_heading, support_closing))
|
%s"""
|
||||||
|
% (support_heading, support_closing)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# zwrite failed and it wasn't because of expired tickets: This is
|
# zwrite failed and it wasn't because of expired tickets: This is
|
||||||
# probably because the recipient isn't subscribed to personals,
|
# probably because the recipient isn't subscribed to personals,
|
||||||
# but regardless, we should just notify the user.
|
# but regardless, we should just notify the user.
|
||||||
send_error_zulip("""%s
|
send_error_zulip(
|
||||||
|
"""%s
|
||||||
|
|
||||||
Your Zulip-Zephyr mirror bot was unable to forward that last message \
|
Your Zulip-Zephyr mirror bot was unable to forward that last message \
|
||||||
from Zulip to Zephyr. That means that while Zulip users (like you) \
|
from Zulip to Zephyr. That means that while Zulip users (like you) \
|
||||||
|
@ -736,20 +824,22 @@ received it, Zephyr users did not. The error message from zwrite was:
|
||||||
|
|
||||||
%s
|
%s
|
||||||
|
|
||||||
%s""" % (support_heading, stderr, support_closing))
|
%s"""
|
||||||
|
% (support_heading, stderr, support_closing)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None:
|
def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None:
|
||||||
# The key string can be used to direct any type of text.
|
# The key string can be used to direct any type of text.
|
||||||
if (message["sender_email"] == zulip_account_email):
|
if message["sender_email"] == zulip_account_email:
|
||||||
if not (
|
if not (
|
||||||
(message["type"] == "stream")
|
(message["type"] == "stream")
|
||||||
or (
|
or (
|
||||||
message["type"] == "private"
|
message["type"] == "private"
|
||||||
and False
|
and False
|
||||||
not in [
|
not in [
|
||||||
u["email"].lower().endswith("mit.edu")
|
u["email"].lower().endswith("mit.edu") for u in message["display_recipient"]
|
||||||
for u in message["display_recipient"]
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
|
@ -758,8 +848,9 @@ def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None:
|
||||||
return
|
return
|
||||||
timestamp_now = int(time.time())
|
timestamp_now = int(time.time())
|
||||||
if float(message["timestamp"]) < timestamp_now - 15:
|
if float(message["timestamp"]) < timestamp_now - 15:
|
||||||
logger.warning("Skipping out of order message: %s < %s" %
|
logger.warning(
|
||||||
(message["timestamp"], timestamp_now))
|
"Skipping out of order message: %s < %s" % (message["timestamp"], timestamp_now)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
forward_to_zephyr(message)
|
forward_to_zephyr(message)
|
||||||
|
@ -768,6 +859,7 @@ def maybe_forward_to_zephyr(message: Dict[str, Any]) -> None:
|
||||||
# whole process
|
# whole process
|
||||||
logger.exception("Error forwarding message:")
|
logger.exception("Error forwarding message:")
|
||||||
|
|
||||||
|
|
||||||
def zulip_to_zephyr(options: optparse.Values) -> NoReturn:
|
def zulip_to_zephyr(options: optparse.Values) -> NoReturn:
|
||||||
# Sync messages from zulip to zephyr
|
# Sync messages from zulip to zephyr
|
||||||
logger.info("Starting syncing messages.")
|
logger.info("Starting syncing messages.")
|
||||||
|
@ -779,6 +871,7 @@ def zulip_to_zephyr(options: optparse.Values) -> NoReturn:
|
||||||
logger.exception("Error syncing messages:")
|
logger.exception("Error syncing messages:")
|
||||||
backoff.fail()
|
backoff.fail()
|
||||||
|
|
||||||
|
|
||||||
def subscribed_to_mail_messages() -> bool:
|
def subscribed_to_mail_messages() -> bool:
|
||||||
# In case we have lost our AFS tokens and those won't be able to
|
# In case we have lost our AFS tokens and those won't be able to
|
||||||
# parse the Zephyr subs file, first try reading in result of this
|
# parse the Zephyr subs file, first try reading in result of this
|
||||||
|
@ -787,12 +880,13 @@ def subscribed_to_mail_messages() -> bool:
|
||||||
if stored_result is not None:
|
if stored_result is not None:
|
||||||
return stored_result == "True"
|
return stored_result == "True"
|
||||||
for (cls, instance, recipient) in parse_zephyr_subs(verbose=False):
|
for (cls, instance, recipient) in parse_zephyr_subs(verbose=False):
|
||||||
if (cls.lower() == "mail" and instance.lower() == "inbox"):
|
if cls.lower() == "mail" and instance.lower() == "inbox":
|
||||||
os.environ["HUMBUG_FORWARD_MAIL_ZEPHYRS"] = "True"
|
os.environ["HUMBUG_FORWARD_MAIL_ZEPHYRS"] = "True"
|
||||||
return True
|
return True
|
||||||
os.environ["HUMBUG_FORWARD_MAIL_ZEPHYRS"] = "False"
|
os.environ["HUMBUG_FORWARD_MAIL_ZEPHYRS"] = "False"
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def add_zulip_subscriptions(verbose: bool) -> None:
|
def add_zulip_subscriptions(verbose: bool) -> None:
|
||||||
zephyr_subscriptions = set()
|
zephyr_subscriptions = set()
|
||||||
skipped = set()
|
skipped = set()
|
||||||
|
@ -805,7 +899,14 @@ def add_zulip_subscriptions(verbose: bool) -> None:
|
||||||
# We don't support subscribing to (message, *)
|
# We don't support subscribing to (message, *)
|
||||||
if instance == "*":
|
if instance == "*":
|
||||||
if recipient == "*":
|
if recipient == "*":
|
||||||
skipped.add((cls, instance, recipient, "subscribing to all of class message is not supported."))
|
skipped.add(
|
||||||
|
(
|
||||||
|
cls,
|
||||||
|
instance,
|
||||||
|
recipient,
|
||||||
|
"subscribing to all of class message is not supported.",
|
||||||
|
)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
# If you're on -i white-magic on zephyr, get on stream white-magic on zulip
|
# If you're on -i white-magic on zephyr, get on stream white-magic on zulip
|
||||||
# instead of subscribing to stream "message" on zulip
|
# instead of subscribing to stream "message" on zulip
|
||||||
|
@ -826,8 +927,10 @@ def add_zulip_subscriptions(verbose: bool) -> None:
|
||||||
zephyr_subscriptions.add(cls)
|
zephyr_subscriptions.add(cls)
|
||||||
|
|
||||||
if len(zephyr_subscriptions) != 0:
|
if len(zephyr_subscriptions) != 0:
|
||||||
res = zulip_client.add_subscriptions(list({"name": stream} for stream in zephyr_subscriptions),
|
res = zulip_client.add_subscriptions(
|
||||||
authorization_errors_fatal=False)
|
list({"name": stream} for stream in zephyr_subscriptions),
|
||||||
|
authorization_errors_fatal=False,
|
||||||
|
)
|
||||||
if res.get("result") != "success":
|
if res.get("result") != "success":
|
||||||
logger.error("Error subscribing to streams:\n%s" % (res["msg"],))
|
logger.error("Error subscribing to streams:\n%s" % (res["msg"],))
|
||||||
return
|
return
|
||||||
|
@ -839,9 +942,15 @@ def add_zulip_subscriptions(verbose: bool) -> None:
|
||||||
if already is not None and len(already) > 0:
|
if already is not None and len(already) > 0:
|
||||||
logger.info("\nAlready subscribed to: %s" % (", ".join(list(already.values())[0]),))
|
logger.info("\nAlready subscribed to: %s" % (", ".join(list(already.values())[0]),))
|
||||||
if new is not None and len(new) > 0:
|
if new is not None and len(new) > 0:
|
||||||
logger.info("\nSuccessfully subscribed to: %s" % (", ".join(list(new.values())[0]),))
|
logger.info(
|
||||||
|
"\nSuccessfully subscribed to: %s" % (", ".join(list(new.values())[0]),)
|
||||||
|
)
|
||||||
if unauthorized is not None and len(unauthorized) > 0:
|
if unauthorized is not None and len(unauthorized) > 0:
|
||||||
logger.info("\n" + "\n".join(textwrap.wrap("""\
|
logger.info(
|
||||||
|
"\n"
|
||||||
|
+ "\n".join(
|
||||||
|
textwrap.wrap(
|
||||||
|
"""\
|
||||||
The following streams you have NOT been subscribed to,
|
The following streams you have NOT been subscribed to,
|
||||||
because they have been configured in Zulip as invitation-only streams.
|
because they have been configured in Zulip as invitation-only streams.
|
||||||
This was done at the request of users of these Zephyr classes, usually
|
This was done at the request of users of these Zephyr classes, usually
|
||||||
|
@ -850,11 +959,19 @@ via zcrypt (in Zulip, we achieve the same privacy goals through invitation-only
|
||||||
If you wish to read these streams in Zulip, you need to contact the people who are
|
If you wish to read these streams in Zulip, you need to contact the people who are
|
||||||
on these streams and already use Zulip. They can subscribe you to them via the
|
on these streams and already use Zulip. They can subscribe you to them via the
|
||||||
"streams" page in the Zulip web interface:
|
"streams" page in the Zulip web interface:
|
||||||
""")) + "\n\n %s" % (", ".join(unauthorized),))
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ "\n\n %s" % (", ".join(unauthorized),)
|
||||||
|
)
|
||||||
|
|
||||||
if len(skipped) > 0:
|
if len(skipped) > 0:
|
||||||
if verbose:
|
if verbose:
|
||||||
logger.info("\n" + "\n".join(textwrap.wrap("""\
|
logger.info(
|
||||||
|
"\n"
|
||||||
|
+ "\n".join(
|
||||||
|
textwrap.wrap(
|
||||||
|
"""\
|
||||||
You have some lines in ~/.zephyr.subs that could not be
|
You have some lines in ~/.zephyr.subs that could not be
|
||||||
synced to your Zulip subscriptions because they do not
|
synced to your Zulip subscriptions because they do not
|
||||||
use "*" as both the instance and recipient and not one of
|
use "*" as both the instance and recipient and not one of
|
||||||
|
@ -863,7 +980,11 @@ Zulip has a mechanism for forwarding. Zulip does not
|
||||||
allow subscribing to only some subjects on a Zulip
|
allow subscribing to only some subjects on a Zulip
|
||||||
stream, so this tool has not created a corresponding
|
stream, so this tool has not created a corresponding
|
||||||
Zulip subscription to these lines in ~/.zephyr.subs:
|
Zulip subscription to these lines in ~/.zephyr.subs:
|
||||||
""")) + "\n")
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
for (cls, instance, recipient, reason) in skipped:
|
for (cls, instance, recipient, reason) in skipped:
|
||||||
if verbose:
|
if verbose:
|
||||||
|
@ -873,15 +994,25 @@ Zulip subscription to these lines in ~/.zephyr.subs:
|
||||||
logger.info(" [%s,%s,%s]" % (cls, instance, recipient))
|
logger.info(" [%s,%s,%s]" % (cls, instance, recipient))
|
||||||
if len(skipped) > 0:
|
if len(skipped) > 0:
|
||||||
if verbose:
|
if verbose:
|
||||||
logger.info("\n" + "\n".join(textwrap.wrap("""\
|
logger.info(
|
||||||
|
"\n"
|
||||||
|
+ "\n".join(
|
||||||
|
textwrap.wrap(
|
||||||
|
"""\
|
||||||
If you wish to be subscribed to any Zulip streams related
|
If you wish to be subscribed to any Zulip streams related
|
||||||
to these .zephyrs.subs lines, please do so via the Zulip
|
to these .zephyrs.subs lines, please do so via the Zulip
|
||||||
web interface.
|
web interface.
|
||||||
""")) + "\n")
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def valid_stream_name(name: str) -> bool:
|
def valid_stream_name(name: str) -> bool:
|
||||||
return name != ""
|
return name != ""
|
||||||
|
|
||||||
|
|
||||||
def parse_zephyr_subs(verbose: bool = False) -> Set[Tuple[str, str, str]]:
|
def parse_zephyr_subs(verbose: bool = False) -> Set[Tuple[str, str, str]]:
|
||||||
zephyr_subscriptions = set() # type: Set[Tuple[str, str, str]]
|
zephyr_subscriptions = set() # type: Set[Tuple[str, str, str]]
|
||||||
subs_file = os.path.join(os.environ["HOME"], ".zephyr.subs")
|
subs_file = os.path.join(os.environ["HOME"], ".zephyr.subs")
|
||||||
|
@ -910,6 +1041,7 @@ def parse_zephyr_subs(verbose: bool = False) -> Set[Tuple[str, str, str]]:
|
||||||
zephyr_subscriptions.add((cls.strip(), instance.strip(), recipient.strip()))
|
zephyr_subscriptions.add((cls.strip(), instance.strip(), recipient.strip()))
|
||||||
return zephyr_subscriptions
|
return zephyr_subscriptions
|
||||||
|
|
||||||
|
|
||||||
def open_logger() -> logging.Logger:
|
def open_logger() -> logging.Logger:
|
||||||
if options.log_path is not None:
|
if options.log_path is not None:
|
||||||
log_file = options.log_path
|
log_file = options.log_path
|
||||||
|
@ -919,8 +1051,7 @@ def open_logger() -> logging.Logger:
|
||||||
else:
|
else:
|
||||||
log_file = "/var/log/zulip/mirror-log"
|
log_file = "/var/log/zulip/mirror-log"
|
||||||
else:
|
else:
|
||||||
f = tempfile.NamedTemporaryFile(prefix="zulip-log.%s." % (options.user,),
|
f = tempfile.NamedTemporaryFile(prefix="zulip-log.%s." % (options.user,), delete=False)
|
||||||
delete=False)
|
|
||||||
log_file = f.name
|
log_file = f.name
|
||||||
# Close the file descriptor, since the logging system will
|
# Close the file descriptor, since the logging system will
|
||||||
# reopen it anyway.
|
# reopen it anyway.
|
||||||
|
@ -935,6 +1066,7 @@ def open_logger() -> logging.Logger:
|
||||||
logger.addHandler(file_handler)
|
logger.addHandler(file_handler)
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
|
||||||
def configure_logger(logger: logging.Logger, direction_name: Optional[str]) -> None:
|
def configure_logger(logger: logging.Logger, direction_name: Optional[str]) -> None:
|
||||||
if direction_name is None:
|
if direction_name is None:
|
||||||
log_format = "%(message)s"
|
log_format = "%(message)s"
|
||||||
|
@ -949,89 +1081,70 @@ def configure_logger(logger: logging.Logger, direction_name: Optional[str]) -> N
|
||||||
for handler in root_logger.handlers:
|
for handler in root_logger.handlers:
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> Tuple[optparse.Values, List[str]]:
|
def parse_args() -> Tuple[optparse.Values, List[str]]:
|
||||||
parser = optparse.OptionParser()
|
parser = optparse.OptionParser()
|
||||||
parser.add_option('--forward-class-messages',
|
parser.add_option(
|
||||||
default=False,
|
'--forward-class-messages', default=False, help=optparse.SUPPRESS_HELP, action='store_true'
|
||||||
help=optparse.SUPPRESS_HELP,
|
)
|
||||||
action='store_true')
|
parser.add_option('--shard', help=optparse.SUPPRESS_HELP)
|
||||||
parser.add_option('--shard',
|
parser.add_option('--noshard', default=False, help=optparse.SUPPRESS_HELP, action='store_true')
|
||||||
help=optparse.SUPPRESS_HELP)
|
parser.add_option('--resend-log', dest='logs_to_resend', help=optparse.SUPPRESS_HELP)
|
||||||
parser.add_option('--noshard',
|
parser.add_option('--enable-resend-log', dest='resend_log_path', help=optparse.SUPPRESS_HELP)
|
||||||
default=False,
|
parser.add_option('--log-path', dest='log_path', help=optparse.SUPPRESS_HELP)
|
||||||
help=optparse.SUPPRESS_HELP,
|
parser.add_option(
|
||||||
action='store_true')
|
'--stream-file-path',
|
||||||
parser.add_option('--resend-log',
|
dest='stream_file_path',
|
||||||
dest='logs_to_resend',
|
default="/home/zulip/public_streams",
|
||||||
help=optparse.SUPPRESS_HELP)
|
help=optparse.SUPPRESS_HELP,
|
||||||
parser.add_option('--enable-resend-log',
|
)
|
||||||
dest='resend_log_path',
|
parser.add_option(
|
||||||
help=optparse.SUPPRESS_HELP)
|
'--no-forward-personals',
|
||||||
parser.add_option('--log-path',
|
dest='forward_personals',
|
||||||
dest='log_path',
|
help=optparse.SUPPRESS_HELP,
|
||||||
help=optparse.SUPPRESS_HELP)
|
default=True,
|
||||||
parser.add_option('--stream-file-path',
|
action='store_false',
|
||||||
dest='stream_file_path',
|
)
|
||||||
default="/home/zulip/public_streams",
|
parser.add_option(
|
||||||
help=optparse.SUPPRESS_HELP)
|
'--forward-mail-zephyrs',
|
||||||
parser.add_option('--no-forward-personals',
|
dest='forward_mail_zephyrs',
|
||||||
dest='forward_personals',
|
help=optparse.SUPPRESS_HELP,
|
||||||
help=optparse.SUPPRESS_HELP,
|
default=False,
|
||||||
default=True,
|
action='store_true',
|
||||||
action='store_false')
|
)
|
||||||
parser.add_option('--forward-mail-zephyrs',
|
parser.add_option(
|
||||||
dest='forward_mail_zephyrs',
|
'--no-forward-from-zulip',
|
||||||
help=optparse.SUPPRESS_HELP,
|
default=True,
|
||||||
default=False,
|
dest='forward_from_zulip',
|
||||||
action='store_true')
|
help=optparse.SUPPRESS_HELP,
|
||||||
parser.add_option('--no-forward-from-zulip',
|
action='store_false',
|
||||||
default=True,
|
)
|
||||||
dest='forward_from_zulip',
|
parser.add_option('--verbose', default=False, help=optparse.SUPPRESS_HELP, action='store_true')
|
||||||
help=optparse.SUPPRESS_HELP,
|
parser.add_option('--sync-subscriptions', default=False, action='store_true')
|
||||||
action='store_false')
|
parser.add_option('--ignore-expired-tickets', default=False, action='store_true')
|
||||||
parser.add_option('--verbose',
|
parser.add_option('--site', default=DEFAULT_SITE, help=optparse.SUPPRESS_HELP)
|
||||||
default=False,
|
parser.add_option('--on-startup-command', default=None, help=optparse.SUPPRESS_HELP)
|
||||||
help=optparse.SUPPRESS_HELP,
|
parser.add_option('--user', default=os.environ["USER"], help=optparse.SUPPRESS_HELP)
|
||||||
action='store_true')
|
parser.add_option(
|
||||||
parser.add_option('--sync-subscriptions',
|
'--stamp-path',
|
||||||
default=False,
|
default="/afs/athena.mit.edu/user/t/a/tabbott/for_friends",
|
||||||
action='store_true')
|
help=optparse.SUPPRESS_HELP,
|
||||||
parser.add_option('--ignore-expired-tickets',
|
)
|
||||||
default=False,
|
parser.add_option('--session-path', default=None, help=optparse.SUPPRESS_HELP)
|
||||||
action='store_true')
|
parser.add_option('--nagios-class', default=None, help=optparse.SUPPRESS_HELP)
|
||||||
parser.add_option('--site',
|
parser.add_option('--nagios-path', default=None, help=optparse.SUPPRESS_HELP)
|
||||||
default=DEFAULT_SITE,
|
parser.add_option(
|
||||||
help=optparse.SUPPRESS_HELP)
|
'--use-sessions', default=False, action='store_true', help=optparse.SUPPRESS_HELP
|
||||||
parser.add_option('--on-startup-command',
|
)
|
||||||
default=None,
|
parser.add_option(
|
||||||
help=optparse.SUPPRESS_HELP)
|
'--test-mode', default=False, help=optparse.SUPPRESS_HELP, action='store_true'
|
||||||
parser.add_option('--user',
|
)
|
||||||
default=os.environ["USER"],
|
parser.add_option(
|
||||||
help=optparse.SUPPRESS_HELP)
|
'--api-key-file', default=os.path.join(os.environ["HOME"], "Private", ".humbug-api-key")
|
||||||
parser.add_option('--stamp-path',
|
)
|
||||||
default="/afs/athena.mit.edu/user/t/a/tabbott/for_friends",
|
|
||||||
help=optparse.SUPPRESS_HELP)
|
|
||||||
parser.add_option('--session-path',
|
|
||||||
default=None,
|
|
||||||
help=optparse.SUPPRESS_HELP)
|
|
||||||
parser.add_option('--nagios-class',
|
|
||||||
default=None,
|
|
||||||
help=optparse.SUPPRESS_HELP)
|
|
||||||
parser.add_option('--nagios-path',
|
|
||||||
default=None,
|
|
||||||
help=optparse.SUPPRESS_HELP)
|
|
||||||
parser.add_option('--use-sessions',
|
|
||||||
default=False,
|
|
||||||
action='store_true',
|
|
||||||
help=optparse.SUPPRESS_HELP)
|
|
||||||
parser.add_option('--test-mode',
|
|
||||||
default=False,
|
|
||||||
help=optparse.SUPPRESS_HELP,
|
|
||||||
action='store_true')
|
|
||||||
parser.add_option('--api-key-file',
|
|
||||||
default=os.path.join(os.environ["HOME"], "Private", ".humbug-api-key"))
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def die_gracefully(signal: int, frame: FrameType) -> None:
|
def die_gracefully(signal: int, frame: FrameType) -> None:
|
||||||
if CURRENT_STATE == States.ZulipToZephyr or CURRENT_STATE == States.ChildSending:
|
if CURRENT_STATE == States.ZulipToZephyr or CURRENT_STATE == States.ChildSending:
|
||||||
# this is a child process, so we want os._exit (no clean-up necessary)
|
# this is a child process, so we want os._exit (no clean-up necessary)
|
||||||
|
@ -1047,6 +1160,7 @@ def die_gracefully(signal: int, frame: FrameType) -> None:
|
||||||
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Set the SIGCHLD handler back to SIG_DFL to prevent these errors
|
# Set the SIGCHLD handler back to SIG_DFL to prevent these errors
|
||||||
# when importing the "requests" module after being restarted using
|
# when importing the "requests" module after being restarted using
|
||||||
|
@ -1070,10 +1184,18 @@ if __name__ == "__main__":
|
||||||
api_key = os.environ.get("HUMBUG_API_KEY")
|
api_key = os.environ.get("HUMBUG_API_KEY")
|
||||||
else:
|
else:
|
||||||
if not os.path.exists(options.api_key_file):
|
if not os.path.exists(options.api_key_file):
|
||||||
logger.error("\n" + "\n".join(textwrap.wrap("""\
|
logger.error(
|
||||||
|
"\n"
|
||||||
|
+ "\n".join(
|
||||||
|
textwrap.wrap(
|
||||||
|
"""\
|
||||||
Could not find API key file.
|
Could not find API key file.
|
||||||
You need to either place your api key file at %s,
|
You need to either place your api key file at %s,
|
||||||
or specify the --api-key-file option.""" % (options.api_key_file,))))
|
or specify the --api-key-file option."""
|
||||||
|
% (options.api_key_file,)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
api_key = open(options.api_key_file).read().strip()
|
api_key = open(options.api_key_file).read().strip()
|
||||||
# Store the API key in the environment so that our children
|
# Store the API key in the environment so that our children
|
||||||
|
@ -1086,12 +1208,14 @@ or specify the --api-key-file option.""" % (options.api_key_file,))))
|
||||||
|
|
||||||
zulip_account_email = options.user + "@mit.edu"
|
zulip_account_email = options.user + "@mit.edu"
|
||||||
import zulip
|
import zulip
|
||||||
|
|
||||||
zulip_client = zulip.Client(
|
zulip_client = zulip.Client(
|
||||||
email=zulip_account_email,
|
email=zulip_account_email,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
verbose=True,
|
verbose=True,
|
||||||
client="zephyr_mirror",
|
client="zephyr_mirror",
|
||||||
site=options.site)
|
site=options.site,
|
||||||
|
)
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
|
@ -1110,9 +1234,11 @@ or specify the --api-key-file option.""" % (options.api_key_file,))))
|
||||||
elif options.user is not None:
|
elif options.user is not None:
|
||||||
# Personals mirror on behalf of another user.
|
# Personals mirror on behalf of another user.
|
||||||
pgrep_query = "%s.*--user=%s" % (pgrep_query, options.user)
|
pgrep_query = "%s.*--user=%s" % (pgrep_query, options.user)
|
||||||
proc = subprocess.Popen(['pgrep', '-U', os.environ["USER"], "-f", pgrep_query],
|
proc = subprocess.Popen(
|
||||||
stdout=subprocess.PIPE,
|
['pgrep', '-U', os.environ["USER"], "-f", pgrep_query],
|
||||||
stderr=subprocess.PIPE)
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
out, _err_unused = proc.communicate()
|
out, _err_unused = proc.communicate()
|
||||||
for pid in map(int, out.split()):
|
for pid in map(int, out.split()):
|
||||||
if pid == os.getpid() or pid == os.getppid():
|
if pid == os.getpid() or pid == os.getppid():
|
||||||
|
@ -1149,6 +1275,7 @@ or specify the --api-key-file option.""" % (options.api_key_file,))))
|
||||||
CURRENT_STATE = States.ZephyrToZulip
|
CURRENT_STATE = States.ZephyrToZulip
|
||||||
|
|
||||||
import zephyr
|
import zephyr
|
||||||
|
|
||||||
logger_name = "zephyr=>zulip"
|
logger_name = "zephyr=>zulip"
|
||||||
if options.shard is not None:
|
if options.shard is not None:
|
||||||
logger_name += "(%s)" % (options.shard,)
|
logger_name += "(%s)" % (options.shard,)
|
||||||
|
|
|
@ -8,20 +8,24 @@ from typing import Any, Dict, Generator, List, Tuple
|
||||||
with open("README.md") as fh:
|
with open("README.md") as fh:
|
||||||
long_description = fh.read()
|
long_description = fh.read()
|
||||||
|
|
||||||
|
|
||||||
def version() -> str:
|
def version() -> str:
|
||||||
version_py = os.path.join(os.path.dirname(__file__), "zulip", "__init__.py")
|
version_py = os.path.join(os.path.dirname(__file__), "zulip", "__init__.py")
|
||||||
with open(version_py) as in_handle:
|
with open(version_py) as in_handle:
|
||||||
version_line = next(itertools.dropwhile(lambda x: not x.startswith("__version__"),
|
version_line = next(
|
||||||
in_handle))
|
itertools.dropwhile(lambda x: not x.startswith("__version__"), in_handle)
|
||||||
|
)
|
||||||
version = version_line.split('=')[-1].strip().replace('"', '')
|
version = version_line.split('=')[-1].strip().replace('"', '')
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
def recur_expand(target_root: Any, dir: Any) -> Generator[Tuple[str, List[str]], None, None]:
|
def recur_expand(target_root: Any, dir: Any) -> Generator[Tuple[str, List[str]], None, None]:
|
||||||
for root, _, files in os.walk(dir):
|
for root, _, files in os.walk(dir):
|
||||||
paths = [os.path.join(root, f) for f in files]
|
paths = [os.path.join(root, f) for f in files]
|
||||||
if len(paths):
|
if len(paths):
|
||||||
yield os.path.join(target_root, root), paths
|
yield os.path.join(target_root, root), paths
|
||||||
|
|
||||||
|
|
||||||
# We should be installable with either setuptools or distutils.
|
# We should be installable with either setuptools or distutils.
|
||||||
package_info = dict(
|
package_info = dict(
|
||||||
name='zulip',
|
name='zulip',
|
||||||
|
@ -56,22 +60,24 @@ package_info = dict(
|
||||||
'zulip-send=zulip.send:main',
|
'zulip-send=zulip.send:main',
|
||||||
'zulip-api-examples=zulip.api_examples:main',
|
'zulip-api-examples=zulip.api_examples:main',
|
||||||
'zulip-matrix-bridge=integrations.bridge_with_matrix.matrix_bridge:main',
|
'zulip-matrix-bridge=integrations.bridge_with_matrix.matrix_bridge:main',
|
||||||
'zulip-api=zulip.cli:cli'
|
'zulip-api=zulip.cli:cli',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
package_data={'zulip': ["py.typed"]},
|
package_data={'zulip': ["py.typed"]},
|
||||||
) # type: Dict[str, Any]
|
) # type: Dict[str, Any]
|
||||||
|
|
||||||
setuptools_info = dict(
|
setuptools_info = dict(
|
||||||
install_requires=['requests[security]>=0.12.1',
|
install_requires=[
|
||||||
'matrix_client',
|
'requests[security]>=0.12.1',
|
||||||
'distro',
|
'matrix_client',
|
||||||
'click',
|
'distro',
|
||||||
],
|
'click',
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from setuptools import find_packages, setup
|
from setuptools import find_packages, setup
|
||||||
|
|
||||||
package_info.update(setuptools_info)
|
package_info.update(setuptools_info)
|
||||||
package_info['packages'] = find_packages(exclude=['tests'])
|
package_info['packages'] = find_packages(exclude=['tests'])
|
||||||
|
|
||||||
|
@ -82,7 +88,8 @@ except ImportError:
|
||||||
# Manual dependency check
|
# Manual dependency check
|
||||||
try:
|
try:
|
||||||
import requests
|
import requests
|
||||||
assert(LooseVersion(requests.__version__) >= LooseVersion('0.12.1'))
|
|
||||||
|
assert LooseVersion(requests.__version__) >= LooseVersion('0.12.1')
|
||||||
except (ImportError, AssertionError):
|
except (ImportError, AssertionError):
|
||||||
print("requests >=0.12.1 is not installed", file=sys.stderr)
|
print("requests >=0.12.1 is not installed", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
|
@ -12,7 +12,6 @@ from zulip import ZulipError
|
||||||
|
|
||||||
|
|
||||||
class TestDefaultArguments(TestCase):
|
class TestDefaultArguments(TestCase):
|
||||||
|
|
||||||
def test_invalid_arguments(self) -> None:
|
def test_invalid_arguments(self) -> None:
|
||||||
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage="lorem ipsum"))
|
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage="lorem ipsum"))
|
||||||
with self.assertRaises(SystemExit) as cm:
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
@ -20,14 +19,18 @@ class TestDefaultArguments(TestCase):
|
||||||
parser.parse_args(['invalid argument'])
|
parser.parse_args(['invalid argument'])
|
||||||
self.assertEqual(cm.exception.code, 2)
|
self.assertEqual(cm.exception.code, 2)
|
||||||
# Assert that invalid arguments exit with printing the full usage (non-standard behavior)
|
# Assert that invalid arguments exit with printing the full usage (non-standard behavior)
|
||||||
self.assertTrue(mock_stderr.getvalue().startswith("""usage: lorem ipsum
|
self.assertTrue(
|
||||||
|
mock_stderr.getvalue().startswith(
|
||||||
|
"""usage: lorem ipsum
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
|
|
||||||
Zulip API configuration:
|
Zulip API configuration:
|
||||||
--site ZULIP_SITE Zulip server URI
|
--site ZULIP_SITE Zulip server URI
|
||||||
"""))
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@patch('os.path.exists', return_value=False)
|
@patch('os.path.exists', return_value=False)
|
||||||
def test_config_path_with_tilde(self, mock_os_path_exists: bool) -> None:
|
def test_config_path_with_tilde(self, mock_os_path_exists: bool) -> None:
|
||||||
|
@ -37,8 +40,12 @@ Zulip API configuration:
|
||||||
with self.assertRaises(ZulipError) as cm:
|
with self.assertRaises(ZulipError) as cm:
|
||||||
zulip.init_from_options(args)
|
zulip.init_from_options(args)
|
||||||
expanded_test_path = os.path.abspath(os.path.expanduser(test_path))
|
expanded_test_path = os.path.abspath(os.path.expanduser(test_path))
|
||||||
self.assertEqual(str(cm.exception), 'api_key or email not specified and '
|
self.assertEqual(
|
||||||
'file {} does not exist'.format(expanded_test_path))
|
str(cm.exception),
|
||||||
|
'api_key or email not specified and '
|
||||||
|
'file {} does not exist'.format(expanded_test_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
@ -20,5 +20,6 @@ class TestHashUtilDecode(TestCase):
|
||||||
with self.subTest(encoded_string=encoded_string):
|
with self.subTest(encoded_string=encoded_string):
|
||||||
self.assertEqual(zulip.hash_util_decode(encoded_string), decoded_string)
|
self.assertEqual(zulip.hash_util_decode(encoded_string), decoded_string)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -10,19 +10,21 @@ def main() -> None:
|
||||||
|
|
||||||
Prints the path to the Zulip API example scripts."""
|
Prints the path to the Zulip API example scripts."""
|
||||||
parser = argparse.ArgumentParser(usage=usage)
|
parser = argparse.ArgumentParser(usage=usage)
|
||||||
parser.add_argument('script_name',
|
parser.add_argument(
|
||||||
nargs='?',
|
'script_name', nargs='?', default='', help='print path to the script <script_name>'
|
||||||
default='',
|
)
|
||||||
help='print path to the script <script_name>')
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
zulip_path = os.path.abspath(os.path.dirname(zulip.__file__))
|
zulip_path = os.path.abspath(os.path.dirname(zulip.__file__))
|
||||||
examples_path = os.path.abspath(os.path.join(zulip_path, 'examples', args.script_name))
|
examples_path = os.path.abspath(os.path.join(zulip_path, 'examples', args.script_name))
|
||||||
if os.path.isdir(examples_path) or (args.script_name and os.path.isfile(examples_path)):
|
if os.path.isdir(examples_path) or (args.script_name and os.path.isfile(examples_path)):
|
||||||
print(examples_path)
|
print(examples_path)
|
||||||
else:
|
else:
|
||||||
raise OSError("Examples cannot be accessed at {}: {} does not exist!"
|
raise OSError(
|
||||||
.format(examples_path,
|
"Examples cannot be accessed at {}: {} does not exist!".format(
|
||||||
"File" if args.script_name else "Directory"))
|
examples_path, "File" if args.script_name else "Directory"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
|
@ -23,9 +23,13 @@ options = parser.parse_args()
|
||||||
|
|
||||||
client = zulip.init_from_options(options)
|
client = zulip.init_from_options(options)
|
||||||
|
|
||||||
print(client.create_user({
|
print(
|
||||||
'email': options.new_email,
|
client.create_user(
|
||||||
'password': options.new_password,
|
{
|
||||||
'full_name': options.new_full_name,
|
'email': options.new_email,
|
||||||
'short_name': options.new_short_name
|
'password': options.new_password,
|
||||||
}))
|
'full_name': options.new_full_name,
|
||||||
|
'short_name': options.new_short_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
@ -29,11 +29,15 @@ options = parser.parse_args()
|
||||||
|
|
||||||
client = zulip.init_from_options(options)
|
client = zulip.init_from_options(options)
|
||||||
|
|
||||||
print(client.update_stream({
|
print(
|
||||||
'stream_id': options.stream_id,
|
client.update_stream(
|
||||||
'description': quote(options.description),
|
{
|
||||||
'new_name': quote(options.new_name),
|
'stream_id': options.stream_id,
|
||||||
'is_private': options.private,
|
'description': quote(options.description),
|
||||||
'is_announcement_only': options.announcement_only,
|
'new_name': quote(options.new_name),
|
||||||
'history_public_to_subscribers': options.history_public_to_subscribers
|
'is_private': options.private,
|
||||||
}))
|
'is_announcement_only': options.announcement_only,
|
||||||
|
'history_public_to_subscribers': options.history_public_to_subscribers,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
@ -15,8 +15,12 @@ Example: get-history --stream announce --topic important"""
|
||||||
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage))
|
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage))
|
||||||
parser.add_argument('--stream', required=True, help="The stream name to get the history")
|
parser.add_argument('--stream', required=True, help="The stream name to get the history")
|
||||||
parser.add_argument('--topic', help="The topic name to get the history")
|
parser.add_argument('--topic', help="The topic name to get the history")
|
||||||
parser.add_argument('--filename', default='history.json', help="The file name to store the fetched \
|
parser.add_argument(
|
||||||
history.\n Default 'history.json'")
|
'--filename',
|
||||||
|
default='history.json',
|
||||||
|
help="The file name to store the fetched \
|
||||||
|
history.\n Default 'history.json'",
|
||||||
|
)
|
||||||
options = parser.parse_args()
|
options = parser.parse_args()
|
||||||
|
|
||||||
client = zulip.init_from_options(options)
|
client = zulip.init_from_options(options)
|
||||||
|
@ -33,7 +37,7 @@ request = {
|
||||||
'num_after': 1000,
|
'num_after': 1000,
|
||||||
'narrow': narrow,
|
'narrow': narrow,
|
||||||
'client_gravatar': False,
|
'client_gravatar': False,
|
||||||
'apply_markdown': False
|
'apply_markdown': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
all_messages = [] # type: List[Dict[str, Any]]
|
all_messages = [] # type: List[Dict[str, Any]]
|
||||||
|
|
|
@ -28,12 +28,16 @@ options = parser.parse_args()
|
||||||
|
|
||||||
client = zulip.init_from_options(options)
|
client = zulip.init_from_options(options)
|
||||||
|
|
||||||
print(client.get_messages({
|
print(
|
||||||
'anchor': options.anchor,
|
client.get_messages(
|
||||||
'use_first_unread_anchor': options.use_first_unread_anchor,
|
{
|
||||||
'num_before': options.num_before,
|
'anchor': options.anchor,
|
||||||
'num_after': options.num_after,
|
'use_first_unread_anchor': options.use_first_unread_anchor,
|
||||||
'narrow': options.narrow,
|
'num_before': options.num_before,
|
||||||
'client_gravatar': options.client_gravatar,
|
'num_after': options.num_after,
|
||||||
'apply_markdown': options.apply_markdown
|
'narrow': options.narrow,
|
||||||
}))
|
'client_gravatar': options.client_gravatar,
|
||||||
|
'apply_markdown': options.apply_markdown,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
@ -18,13 +18,10 @@ options = parser.parse_args()
|
||||||
|
|
||||||
client = zulip.init_from_options(options)
|
client = zulip.init_from_options(options)
|
||||||
|
|
||||||
OPERATIONS = {
|
OPERATIONS = {'mute': 'add', 'unmute': 'remove'}
|
||||||
'mute': 'add',
|
|
||||||
'unmute': 'remove'
|
|
||||||
}
|
|
||||||
|
|
||||||
print(client.mute_topic({
|
print(
|
||||||
'op': OPERATIONS[options.op],
|
client.mute_topic(
|
||||||
'stream': options.stream,
|
{'op': OPERATIONS[options.op], 'stream': options.stream, 'topic': options.topic}
|
||||||
'topic': options.topic
|
)
|
||||||
}))
|
)
|
||||||
|
|
|
@ -19,9 +19,11 @@ options = parser.parse_args()
|
||||||
|
|
||||||
client = zulip.init_from_options(options)
|
client = zulip.init_from_options(options)
|
||||||
|
|
||||||
|
|
||||||
def print_event(event: Dict[str, Any]) -> None:
|
def print_event(event: Dict[str, Any]) -> None:
|
||||||
print(event)
|
print(event)
|
||||||
|
|
||||||
|
|
||||||
# This is a blocking call, and will continuously poll for new events
|
# This is a blocking call, and will continuously poll for new events
|
||||||
# Note also the filter here is messages to the stream Denmark; if you
|
# Note also the filter here is messages to the stream Denmark; if you
|
||||||
# don't specify event_types it'll print all events.
|
# don't specify event_types it'll print all events.
|
||||||
|
|
|
@ -19,8 +19,10 @@ options = parser.parse_args()
|
||||||
|
|
||||||
client = zulip.init_from_options(options)
|
client = zulip.init_from_options(options)
|
||||||
|
|
||||||
|
|
||||||
def print_message(message: Dict[str, Any]) -> None:
|
def print_message(message: Dict[str, Any]) -> None:
|
||||||
print(message)
|
print(message)
|
||||||
|
|
||||||
|
|
||||||
# This is a blocking call, and will continuously poll for new messages
|
# This is a blocking call, and will continuously poll for new messages
|
||||||
client.call_on_each_message(print_message)
|
client.call_on_each_message(print_message)
|
||||||
|
|
|
@ -20,5 +20,4 @@ options = parser.parse_args()
|
||||||
|
|
||||||
client = zulip.init_from_options(options)
|
client = zulip.init_from_options(options)
|
||||||
|
|
||||||
print(client.add_subscriptions([{"name": stream_name} for stream_name in
|
print(client.add_subscriptions([{"name": stream_name} for stream_name in options.streams.split()]))
|
||||||
options.streams.split()]))
|
|
||||||
|
|
|
@ -19,8 +19,8 @@ options = parser.parse_args()
|
||||||
|
|
||||||
client = zulip.init_from_options(options)
|
client = zulip.init_from_options(options)
|
||||||
|
|
||||||
print(client.update_message_flags({
|
print(
|
||||||
'op': options.op,
|
client.update_message_flags(
|
||||||
'flag': options.flag,
|
{'op': options.op, 'flag': options.flag, 'messages': options.messages}
|
||||||
'messages': options.messages
|
)
|
||||||
}))
|
)
|
||||||
|
|
|
@ -10,6 +10,7 @@ import zulip
|
||||||
class StringIO(_StringIO):
|
class StringIO(_StringIO):
|
||||||
name = '' # https://github.com/python/typeshed/issues/598
|
name = '' # https://github.com/python/typeshed/issues/598
|
||||||
|
|
||||||
|
|
||||||
usage = """upload-file [options]
|
usage = """upload-file [options]
|
||||||
|
|
||||||
Upload a file, and print the corresponding URI.
|
Upload a file, and print the corresponding URI.
|
||||||
|
|
|
@ -52,13 +52,16 @@ streams_to_watch = ['new members']
|
||||||
# These streams will cause anyone who sends a message there to be removed from the watchlist
|
# These streams will cause anyone who sends a message there to be removed from the watchlist
|
||||||
streams_to_cancel = ['development help']
|
streams_to_cancel = ['development help']
|
||||||
|
|
||||||
|
|
||||||
def get_watchlist() -> List[Any]:
|
def get_watchlist() -> List[Any]:
|
||||||
storage = client.get_storage()
|
storage = client.get_storage()
|
||||||
return list(storage['storage'].values())
|
return list(storage['storage'].values())
|
||||||
|
|
||||||
|
|
||||||
def set_watchlist(watchlist: List[str]) -> None:
|
def set_watchlist(watchlist: List[str]) -> None:
|
||||||
client.update_storage({'storage': dict(enumerate(watchlist))})
|
client.update_storage({'storage': dict(enumerate(watchlist))})
|
||||||
|
|
||||||
|
|
||||||
def handle_event(event: Dict[str, Any]) -> None:
|
def handle_event(event: Dict[str, Any]) -> None:
|
||||||
try:
|
try:
|
||||||
if event['type'] == 'realm_user' and event['op'] == 'add':
|
if event['type'] == 'realm_user' and event['op'] == 'add':
|
||||||
|
@ -74,11 +77,13 @@ def handle_event(event: Dict[str, Any]) -> None:
|
||||||
if event['message']['sender_email'] in watchlist:
|
if event['message']['sender_email'] in watchlist:
|
||||||
watchlist.remove(event['message']['sender_email'])
|
watchlist.remove(event['message']['sender_email'])
|
||||||
if stream not in streams_to_cancel:
|
if stream not in streams_to_cancel:
|
||||||
client.send_message({
|
client.send_message(
|
||||||
'type': 'private',
|
{
|
||||||
'to': event['message']['sender_email'],
|
'type': 'private',
|
||||||
'content': welcome_text.format(event['message']['sender_short_name'])
|
'to': event['message']['sender_email'],
|
||||||
})
|
'content': welcome_text.format(event['message']['sender_short_name']),
|
||||||
|
}
|
||||||
|
)
|
||||||
set_watchlist(watchlist)
|
set_watchlist(watchlist)
|
||||||
return
|
return
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
|
@ -89,5 +94,6 @@ def start_event_handler() -> None:
|
||||||
print("Starting event handler...")
|
print("Starting event handler...")
|
||||||
client.call_on_each_event(handle_event, event_types=['realm_user', 'message'])
|
client.call_on_each_event(handle_event, event_types=['realm_user', 'message'])
|
||||||
|
|
||||||
|
|
||||||
client = zulip.Client()
|
client = zulip.Client()
|
||||||
start_event_handler()
|
start_event_handler()
|
||||||
|
|
|
@ -12,12 +12,15 @@ logging.basicConfig()
|
||||||
|
|
||||||
log = logging.getLogger('zulip-send')
|
log = logging.getLogger('zulip-send')
|
||||||
|
|
||||||
|
|
||||||
def do_send_message(client: zulip.Client, message_data: Dict[str, Any]) -> bool:
|
def do_send_message(client: zulip.Client, message_data: Dict[str, Any]) -> bool:
|
||||||
'''Sends a message and optionally prints status about the same.'''
|
'''Sends a message and optionally prints status about the same.'''
|
||||||
|
|
||||||
if message_data['type'] == 'stream':
|
if message_data['type'] == 'stream':
|
||||||
log.info('Sending message to stream "%s", subject "%s"... ' %
|
log.info(
|
||||||
(message_data['to'], message_data['subject']))
|
'Sending message to stream "%s", subject "%s"... '
|
||||||
|
% (message_data['to'], message_data['subject'])
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
log.info('Sending message to %s... ' % (message_data['to'],))
|
log.info('Sending message to %s... ' % (message_data['to'],))
|
||||||
response = client.send_message(message_data)
|
response = client.send_message(message_data)
|
||||||
|
@ -28,6 +31,7 @@ def do_send_message(client: zulip.Client, message_data: Dict[str, Any]) -> bool:
|
||||||
log.error(response['msg'])
|
log.error(response['msg'])
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
usage = """zulip-send [options] [recipient...]
|
usage = """zulip-send [options] [recipient...]
|
||||||
|
|
||||||
|
@ -41,22 +45,29 @@ def main() -> int:
|
||||||
|
|
||||||
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage))
|
parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage))
|
||||||
|
|
||||||
parser.add_argument('recipients',
|
parser.add_argument(
|
||||||
nargs='*',
|
'recipients', nargs='*', help='email addresses of the recipients of the message'
|
||||||
help='email addresses of the recipients of the message')
|
)
|
||||||
|
|
||||||
parser.add_argument('-m', '--message',
|
parser.add_argument(
|
||||||
help='Specifies the message to send, prevents interactive prompting.')
|
'-m', '--message', help='Specifies the message to send, prevents interactive prompting.'
|
||||||
|
)
|
||||||
|
|
||||||
group = parser.add_argument_group('Stream parameters')
|
group = parser.add_argument_group('Stream parameters')
|
||||||
group.add_argument('-s', '--stream',
|
group.add_argument(
|
||||||
dest='stream',
|
'-s',
|
||||||
action='store',
|
'--stream',
|
||||||
help='Allows the user to specify a stream for the message.')
|
dest='stream',
|
||||||
group.add_argument('-S', '--subject',
|
action='store',
|
||||||
dest='subject',
|
help='Allows the user to specify a stream for the message.',
|
||||||
action='store',
|
)
|
||||||
help='Allows the user to specify a subject for the message.')
|
group.add_argument(
|
||||||
|
'-S',
|
||||||
|
'--subject',
|
||||||
|
dest='subject',
|
||||||
|
action='store',
|
||||||
|
help='Allows the user to specify a subject for the message.',
|
||||||
|
)
|
||||||
|
|
||||||
options = parser.parse_args()
|
options = parser.parse_args()
|
||||||
|
|
||||||
|
@ -93,5 +104,6 @@ def main() -> int:
|
||||||
return 1
|
return 1
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|
|
@ -52,7 +52,7 @@ package_info = dict(
|
||||||
entry_points={
|
entry_points={
|
||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
'zulip-run-bot=zulip_bots.run:main',
|
'zulip-run-bot=zulip_bots.run:main',
|
||||||
'zulip-terminal=zulip_bots.terminal:main'
|
'zulip-terminal=zulip_bots.terminal:main',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
|
@ -71,6 +71,7 @@ setuptools_info = dict(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from setuptools import find_packages, setup
|
from setuptools import find_packages, setup
|
||||||
|
|
||||||
package_info.update(setuptools_info)
|
package_info.update(setuptools_info)
|
||||||
package_info['packages'] = find_packages()
|
package_info['packages'] = find_packages()
|
||||||
package_info['package_data'] = package_data
|
package_info['package_data'] = package_data
|
||||||
|
@ -85,11 +86,13 @@ except ImportError:
|
||||||
try:
|
try:
|
||||||
module = import_module(module_name) # type: Any
|
module = import_module(module_name) # type: Any
|
||||||
if version is not None:
|
if version is not None:
|
||||||
assert(LooseVersion(module.__version__) >= LooseVersion(version))
|
assert LooseVersion(module.__version__) >= LooseVersion(version)
|
||||||
except (ImportError, AssertionError):
|
except (ImportError, AssertionError):
|
||||||
if version is not None:
|
if version is not None:
|
||||||
print("{name}>={version} is not installed.".format(
|
print(
|
||||||
name=module_name, version=version), file=sys.stderr)
|
"{name}>={version} is not installed.".format(name=module_name, version=version),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print("{name} is not installed.".format(name=module_name), file=sys.stderr)
|
print("{name} is not installed.".format(name=module_name), file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
|
@ -12,22 +12,29 @@ class BaremetricsHandler:
|
||||||
self.config_info = bot_handler.get_config_info('baremetrics')
|
self.config_info = bot_handler.get_config_info('baremetrics')
|
||||||
self.api_key = self.config_info['api_key']
|
self.api_key = self.config_info['api_key']
|
||||||
|
|
||||||
self.auth_header = {
|
self.auth_header = {'Authorization': 'Bearer ' + self.api_key}
|
||||||
'Authorization': 'Bearer ' + self.api_key
|
|
||||||
}
|
|
||||||
|
|
||||||
self.commands = ['help',
|
self.commands = [
|
||||||
'list-commands',
|
'help',
|
||||||
'account-info',
|
'list-commands',
|
||||||
'list-sources',
|
'account-info',
|
||||||
'list-plans <source_id>',
|
'list-sources',
|
||||||
'list-customers <source_id>',
|
'list-plans <source_id>',
|
||||||
'list-subscriptions <source_id>',
|
'list-customers <source_id>',
|
||||||
'create-plan <source_id> <oid> <name> <currency> <amount> <interval> <interval_count>']
|
'list-subscriptions <source_id>',
|
||||||
|
'create-plan <source_id> <oid> <name> <currency> <amount> <interval> <interval_count>',
|
||||||
|
]
|
||||||
|
|
||||||
self.descriptions = ['Display bot info', 'Display the list of available commands', 'Display the account info',
|
self.descriptions = [
|
||||||
'List the sources', 'List the plans for the source', 'List the customers in the source',
|
'Display bot info',
|
||||||
'List the subscriptions in the source', 'Create a plan in the given source']
|
'Display the list of available commands',
|
||||||
|
'Display the account info',
|
||||||
|
'List the sources',
|
||||||
|
'List the plans for the source',
|
||||||
|
'List the customers in the source',
|
||||||
|
'List the subscriptions in the source',
|
||||||
|
'Create a plan in the given source',
|
||||||
|
]
|
||||||
|
|
||||||
self.check_api_key(bot_handler)
|
self.check_api_key(bot_handler)
|
||||||
|
|
||||||
|
@ -114,13 +121,16 @@ class BaremetricsHandler:
|
||||||
account_data = account_response.json()
|
account_data = account_response.json()
|
||||||
account_data = account_data['account']
|
account_data = account_data['account']
|
||||||
|
|
||||||
template = ['**Your account information:**',
|
template = [
|
||||||
'Id: {id}',
|
'**Your account information:**',
|
||||||
'Company: {company}',
|
'Id: {id}',
|
||||||
'Default Currency: {currency}']
|
'Company: {company}',
|
||||||
|
'Default Currency: {currency}',
|
||||||
|
]
|
||||||
|
|
||||||
return "\n".join(template).format(currency=account_data['default_currency']['name'],
|
return "\n".join(template).format(
|
||||||
**account_data)
|
currency=account_data['default_currency']['name'], **account_data
|
||||||
|
)
|
||||||
|
|
||||||
def get_sources(self) -> str:
|
def get_sources(self) -> str:
|
||||||
url = 'https://api.baremetrics.com/v1/sources'
|
url = 'https://api.baremetrics.com/v1/sources'
|
||||||
|
@ -131,9 +141,9 @@ class BaremetricsHandler:
|
||||||
|
|
||||||
response = '**Listing sources:** \n'
|
response = '**Listing sources:** \n'
|
||||||
for index, source in enumerate(sources_data):
|
for index, source in enumerate(sources_data):
|
||||||
response += ('{_count}.ID: {id}\n'
|
response += (
|
||||||
'Provider: {provider}\n'
|
'{_count}.ID: {id}\n' 'Provider: {provider}\n' 'Provider ID: {provider_id}\n\n'
|
||||||
'Provider ID: {provider_id}\n\n').format(_count=index + 1, **source)
|
).format(_count=index + 1, **source)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -144,19 +154,20 @@ class BaremetricsHandler:
|
||||||
plans_data = plans_response.json()
|
plans_data = plans_response.json()
|
||||||
plans_data = plans_data['plans']
|
plans_data = plans_data['plans']
|
||||||
|
|
||||||
template = '\n'.join(['{_count}.Name: {name}',
|
template = '\n'.join(
|
||||||
'Active: {active}',
|
[
|
||||||
'Interval: {interval}',
|
'{_count}.Name: {name}',
|
||||||
'Interval Count: {interval_count}',
|
'Active: {active}',
|
||||||
'Amounts:'])
|
'Interval: {interval}',
|
||||||
|
'Interval Count: {interval_count}',
|
||||||
|
'Amounts:',
|
||||||
|
]
|
||||||
|
)
|
||||||
response = ['**Listing plans:**']
|
response = ['**Listing plans:**']
|
||||||
for index, plan in enumerate(plans_data):
|
for index, plan in enumerate(plans_data):
|
||||||
response += (
|
response += (
|
||||||
[template.format(_count=index + 1, **plan)]
|
[template.format(_count=index + 1, **plan)]
|
||||||
+ [
|
+ [' - {amount} {currency}'.format(**amount) for amount in plan['amounts']]
|
||||||
' - {amount} {currency}'.format(**amount)
|
|
||||||
for amount in plan['amounts']
|
|
||||||
]
|
|
||||||
+ ['']
|
+ ['']
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -170,13 +181,17 @@ class BaremetricsHandler:
|
||||||
customers_data = customers_data['customers']
|
customers_data = customers_data['customers']
|
||||||
|
|
||||||
# FIXME BUG here? mismatch of name and display name?
|
# FIXME BUG here? mismatch of name and display name?
|
||||||
template = '\n'.join(['{_count}.Name: {display_name}',
|
template = '\n'.join(
|
||||||
'Display Name: {name}',
|
[
|
||||||
'OID: {oid}',
|
'{_count}.Name: {display_name}',
|
||||||
'Active: {is_active}',
|
'Display Name: {name}',
|
||||||
'Email: {email}',
|
'OID: {oid}',
|
||||||
'Notes: {notes}',
|
'Active: {is_active}',
|
||||||
'Current Plans:'])
|
'Email: {email}',
|
||||||
|
'Notes: {notes}',
|
||||||
|
'Current Plans:',
|
||||||
|
]
|
||||||
|
)
|
||||||
response = ['**Listing customers:**']
|
response = ['**Listing customers:**']
|
||||||
for index, customer in enumerate(customers_data):
|
for index, customer in enumerate(customers_data):
|
||||||
response += (
|
response += (
|
||||||
|
@ -194,13 +209,17 @@ class BaremetricsHandler:
|
||||||
subscriptions_data = subscriptions_response.json()
|
subscriptions_data = subscriptions_response.json()
|
||||||
subscriptions_data = subscriptions_data['subscriptions']
|
subscriptions_data = subscriptions_data['subscriptions']
|
||||||
|
|
||||||
template = '\n'.join(['{_count}.Customer Name: {name}',
|
template = '\n'.join(
|
||||||
'Customer Display Name: {display_name}',
|
[
|
||||||
'Customer OID: {oid}',
|
'{_count}.Customer Name: {name}',
|
||||||
'Customer Email: {email}',
|
'Customer Display Name: {display_name}',
|
||||||
'Active: {_active}',
|
'Customer OID: {oid}',
|
||||||
'Plan Name: {_plan_name}',
|
'Customer Email: {email}',
|
||||||
'Plan Amounts:'])
|
'Active: {_active}',
|
||||||
|
'Plan Name: {_plan_name}',
|
||||||
|
'Plan Amounts:',
|
||||||
|
]
|
||||||
|
)
|
||||||
response = ['**Listing subscriptions:**']
|
response = ['**Listing subscriptions:**']
|
||||||
for index, subscription in enumerate(subscriptions_data):
|
for index, subscription in enumerate(subscriptions_data):
|
||||||
response += (
|
response += (
|
||||||
|
@ -209,7 +228,7 @@ class BaremetricsHandler:
|
||||||
_count=index + 1,
|
_count=index + 1,
|
||||||
_active=subscription['active'],
|
_active=subscription['active'],
|
||||||
_plan_name=subscription['plan']['name'],
|
_plan_name=subscription['plan']['name'],
|
||||||
**subscription['customer']
|
**subscription['customer'],
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
+ [
|
+ [
|
||||||
|
@ -228,7 +247,7 @@ class BaremetricsHandler:
|
||||||
'currency': parameters[3],
|
'currency': parameters[3],
|
||||||
'amount': int(parameters[4]),
|
'amount': int(parameters[4]),
|
||||||
'interval': parameters[5],
|
'interval': parameters[5],
|
||||||
'interval_count': int(parameters[6])
|
'interval_count': int(parameters[6]),
|
||||||
} # type: Any
|
} # type: Any
|
||||||
|
|
||||||
url = 'https://api.baremetrics.com/v1/{}/plans'.format(parameters[0])
|
url = 'https://api.baremetrics.com/v1/{}/plans'.format(parameters[0])
|
||||||
|
@ -238,4 +257,5 @@ class BaremetricsHandler:
|
||||||
else:
|
else:
|
||||||
return 'Invalid Arguments Error.'
|
return 'Invalid Arguments Error.'
|
||||||
|
|
||||||
|
|
||||||
handler_class = BaremetricsHandler
|
handler_class = BaremetricsHandler
|
||||||
|
|
|
@ -8,68 +8,83 @@ class TestBaremetricsBot(BotTestCase, DefaultTests):
|
||||||
bot_name = "baremetrics"
|
bot_name = "baremetrics"
|
||||||
|
|
||||||
def test_bot_responds_to_empty_message(self) -> None:
|
def test_bot_responds_to_empty_message(self) -> None:
|
||||||
with self.mock_config_info({'api_key': 'TEST'}), \
|
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
|
||||||
patch('requests.get'):
|
|
||||||
self.verify_reply('', 'No Command Specified')
|
self.verify_reply('', 'No Command Specified')
|
||||||
|
|
||||||
def test_help_query(self) -> None:
|
def test_help_query(self) -> None:
|
||||||
with self.mock_config_info({'api_key': 'TEST'}), \
|
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
|
||||||
patch('requests.get'):
|
self.verify_reply(
|
||||||
self.verify_reply('help', '''
|
'help',
|
||||||
|
'''
|
||||||
This bot gives updates about customer behavior, financial performance, and analytics
|
This bot gives updates about customer behavior, financial performance, and analytics
|
||||||
for an organization using the Baremetrics Api.\n
|
for an organization using the Baremetrics Api.\n
|
||||||
Enter `list-commands` to show the list of available commands.
|
Enter `list-commands` to show the list of available commands.
|
||||||
Version 1.0
|
Version 1.0
|
||||||
''')
|
''',
|
||||||
|
)
|
||||||
|
|
||||||
def test_list_commands_command(self) -> None:
|
def test_list_commands_command(self) -> None:
|
||||||
with self.mock_config_info({'api_key': 'TEST'}), \
|
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
|
||||||
patch('requests.get'):
|
self.verify_reply(
|
||||||
self.verify_reply('list-commands', '**Available Commands:** \n'
|
'list-commands',
|
||||||
' - help : Display bot info\n'
|
'**Available Commands:** \n'
|
||||||
' - list-commands : Display the list of available commands\n'
|
' - help : Display bot info\n'
|
||||||
' - account-info : Display the account info\n'
|
' - list-commands : Display the list of available commands\n'
|
||||||
' - list-sources : List the sources\n'
|
' - account-info : Display the account info\n'
|
||||||
' - list-plans <source_id> : List the plans for the source\n'
|
' - list-sources : List the sources\n'
|
||||||
' - list-customers <source_id> : List the customers in the source\n'
|
' - list-plans <source_id> : List the plans for the source\n'
|
||||||
' - list-subscriptions <source_id> : List the subscriptions in the '
|
' - list-customers <source_id> : List the customers in the source\n'
|
||||||
'source\n'
|
' - list-subscriptions <source_id> : List the subscriptions in the '
|
||||||
' - create-plan <source_id> <oid> <name> <currency> <amount> <interval> '
|
'source\n'
|
||||||
'<interval_count> : Create a plan in the given source\n')
|
' - create-plan <source_id> <oid> <name> <currency> <amount> <interval> '
|
||||||
|
'<interval_count> : Create a plan in the given source\n',
|
||||||
|
)
|
||||||
|
|
||||||
def test_account_info_command(self) -> None:
|
def test_account_info_command(self) -> None:
|
||||||
with self.mock_config_info({'api_key': 'TEST'}):
|
with self.mock_config_info({'api_key': 'TEST'}):
|
||||||
with self.mock_http_conversation('account_info'):
|
with self.mock_http_conversation('account_info'):
|
||||||
self.verify_reply('account-info', '**Your account information:**\nId: 376418\nCompany: NA\nDefault '
|
self.verify_reply(
|
||||||
'Currency: United States Dollar')
|
'account-info',
|
||||||
|
'**Your account information:**\nId: 376418\nCompany: NA\nDefault '
|
||||||
|
'Currency: United States Dollar',
|
||||||
|
)
|
||||||
|
|
||||||
def test_list_sources_command(self) -> None:
|
def test_list_sources_command(self) -> None:
|
||||||
with self.mock_config_info({'api_key': 'TEST'}):
|
with self.mock_config_info({'api_key': 'TEST'}):
|
||||||
with self.mock_http_conversation('list_sources'):
|
with self.mock_http_conversation('list_sources'):
|
||||||
self.verify_reply('list-sources', '**Listing sources:** \n1.ID: 5f7QC5NC0Ywgcu\nProvider: '
|
self.verify_reply(
|
||||||
'baremetrics\nProvider ID: None\n\n')
|
'list-sources',
|
||||||
|
'**Listing sources:** \n1.ID: 5f7QC5NC0Ywgcu\nProvider: '
|
||||||
|
'baremetrics\nProvider ID: None\n\n',
|
||||||
|
)
|
||||||
|
|
||||||
def test_list_plans_command(self) -> None:
|
def test_list_plans_command(self) -> None:
|
||||||
r = '**Listing plans:**\n1.Name: Plan 1\nActive: True\nInterval: year\nInterval Count: 1\nAmounts:\n' \
|
r = (
|
||||||
' - 450000 USD\n\n2.Name: Plan 2\nActive: True\nInterval: year\nInterval Count: 1\nAmounts:\n' \
|
'**Listing plans:**\n1.Name: Plan 1\nActive: True\nInterval: year\nInterval Count: 1\nAmounts:\n'
|
||||||
|
' - 450000 USD\n\n2.Name: Plan 2\nActive: True\nInterval: year\nInterval Count: 1\nAmounts:\n'
|
||||||
' - 450000 USD\n'
|
' - 450000 USD\n'
|
||||||
|
)
|
||||||
|
|
||||||
with self.mock_config_info({'api_key': 'TEST'}):
|
with self.mock_config_info({'api_key': 'TEST'}):
|
||||||
with self.mock_http_conversation('list_plans'):
|
with self.mock_http_conversation('list_plans'):
|
||||||
self.verify_reply('list-plans TEST', r)
|
self.verify_reply('list-plans TEST', r)
|
||||||
|
|
||||||
def test_list_customers_command(self) -> None:
|
def test_list_customers_command(self) -> None:
|
||||||
r = '**Listing customers:**\n1.Name: Customer 1\nDisplay Name: Customer 1\nOID: customer_1\nActive: True\n' \
|
r = (
|
||||||
|
'**Listing customers:**\n1.Name: Customer 1\nDisplay Name: Customer 1\nOID: customer_1\nActive: True\n'
|
||||||
'Email: customer_1@baremetrics.com\nNotes: Here are some notes\nCurrent Plans:\n - Plan 1\n'
|
'Email: customer_1@baremetrics.com\nNotes: Here are some notes\nCurrent Plans:\n - Plan 1\n'
|
||||||
|
)
|
||||||
|
|
||||||
with self.mock_config_info({'api_key': 'TEST'}):
|
with self.mock_config_info({'api_key': 'TEST'}):
|
||||||
with self.mock_http_conversation('list_customers'):
|
with self.mock_http_conversation('list_customers'):
|
||||||
self.verify_reply('list-customers TEST', r)
|
self.verify_reply('list-customers TEST', r)
|
||||||
|
|
||||||
def test_list_subscriptions_command(self) -> None:
|
def test_list_subscriptions_command(self) -> None:
|
||||||
r = '**Listing subscriptions:**\n1.Customer Name: Customer 1\nCustomer Display Name: Customer 1\n' \
|
r = (
|
||||||
'Customer OID: customer_1\nCustomer Email: customer_1@baremetrics.com\nActive: True\n' \
|
'**Listing subscriptions:**\n1.Customer Name: Customer 1\nCustomer Display Name: Customer 1\n'
|
||||||
|
'Customer OID: customer_1\nCustomer Email: customer_1@baremetrics.com\nActive: True\n'
|
||||||
'Plan Name: Plan 1\nPlan Amounts:\n - 1000 $\n'
|
'Plan Name: Plan 1\nPlan Amounts:\n - 1000 $\n'
|
||||||
|
)
|
||||||
|
|
||||||
with self.mock_config_info({'api_key': 'TEST'}):
|
with self.mock_config_info({'api_key': 'TEST'}):
|
||||||
with self.mock_http_conversation('list_subscriptions'):
|
with self.mock_http_conversation('list_subscriptions'):
|
||||||
|
@ -84,34 +99,30 @@ class TestBaremetricsBot(BotTestCase, DefaultTests):
|
||||||
bot_test_instance.initialize(StubBotHandler())
|
bot_test_instance.initialize(StubBotHandler())
|
||||||
|
|
||||||
def test_invalid_command(self) -> None:
|
def test_invalid_command(self) -> None:
|
||||||
with self.mock_config_info({'api_key': 'TEST'}), \
|
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
|
||||||
patch('requests.get'):
|
|
||||||
self.verify_reply('abcd', 'Invalid Command.')
|
self.verify_reply('abcd', 'Invalid Command.')
|
||||||
|
|
||||||
def test_missing_params(self) -> None:
|
def test_missing_params(self) -> None:
|
||||||
with self.mock_config_info({'api_key': 'TEST'}), \
|
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
|
||||||
patch('requests.get'):
|
|
||||||
self.verify_reply('list-plans', 'Missing Params.')
|
self.verify_reply('list-plans', 'Missing Params.')
|
||||||
|
|
||||||
def test_key_error(self) -> None:
|
def test_key_error(self) -> None:
|
||||||
with self.mock_config_info({'api_key': 'TEST'}), \
|
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
|
||||||
patch('requests.get'):
|
|
||||||
with self.mock_http_conversation('test_key_error'):
|
with self.mock_http_conversation('test_key_error'):
|
||||||
self.verify_reply('list-plans TEST', 'Invalid Response From API.')
|
self.verify_reply('list-plans TEST', 'Invalid Response From API.')
|
||||||
|
|
||||||
def test_create_plan_command(self) -> None:
|
def test_create_plan_command(self) -> None:
|
||||||
with self.mock_config_info({'api_key': 'TEST'}), \
|
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
|
||||||
patch('requests.get'):
|
|
||||||
with self.mock_http_conversation('create_plan'):
|
with self.mock_http_conversation('create_plan'):
|
||||||
self.verify_reply('create-plan TEST 1 TEST USD 123 TEST 123', 'Plan Created.')
|
self.verify_reply('create-plan TEST 1 TEST USD 123 TEST 123', 'Plan Created.')
|
||||||
|
|
||||||
def test_create_plan_error_command(self) -> None:
|
def test_create_plan_error_command(self) -> None:
|
||||||
with self.mock_config_info({'api_key': 'TEST'}), \
|
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
|
||||||
patch('requests.get'):
|
|
||||||
with self.mock_http_conversation('create_plan_error'):
|
with self.mock_http_conversation('create_plan_error'):
|
||||||
self.verify_reply('create-plan TEST 1 TEST USD 123 TEST 123', 'Invalid Arguments Error.')
|
self.verify_reply(
|
||||||
|
'create-plan TEST 1 TEST USD 123 TEST 123', 'Invalid Arguments Error.'
|
||||||
|
)
|
||||||
|
|
||||||
def test_create_plan_argnum_error_command(self) -> None:
|
def test_create_plan_argnum_error_command(self) -> None:
|
||||||
with self.mock_config_info({'api_key': 'TEST'}), \
|
with self.mock_config_info({'api_key': 'TEST'}), patch('requests.get'):
|
||||||
patch('requests.get'):
|
|
||||||
self.verify_reply('create-plan alpha beta', 'Invalid number of arguments.')
|
self.verify_reply('create-plan alpha beta', 'Invalid number of arguments.')
|
||||||
|
|
|
@ -16,6 +16,7 @@ following the syntax shown below :smile:.\n \
|
||||||
\n* `comment`**:** Add a comment [**NOTE:** Optional field, default is *None*]\
|
\n* `comment`**:** Add a comment [**NOTE:** Optional field, default is *None*]\
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
def get_beeminder_response(message_content: str, config_info: Dict[str, str]) -> str:
|
def get_beeminder_response(message_content: str, config_info: Dict[str, str]) -> str:
|
||||||
username = config_info['username']
|
username = config_info['username']
|
||||||
goalname = config_info['goalname']
|
goalname = config_info['goalname']
|
||||||
|
@ -25,37 +26,36 @@ def get_beeminder_response(message_content: str, config_info: Dict[str, str]) ->
|
||||||
if message_content == '' or message_content == 'help':
|
if message_content == '' or message_content == 'help':
|
||||||
return help_message
|
return help_message
|
||||||
|
|
||||||
url = "https://www.beeminder.com/api/v1/users/{}/goals/{}/datapoints.json".format(username, goalname)
|
url = "https://www.beeminder.com/api/v1/users/{}/goals/{}/datapoints.json".format(
|
||||||
|
username, goalname
|
||||||
|
)
|
||||||
message_pieces = message_content.split(',')
|
message_pieces = message_content.split(',')
|
||||||
for i in range(len(message_pieces)):
|
for i in range(len(message_pieces)):
|
||||||
message_pieces[i] = message_pieces[i].strip()
|
message_pieces[i] = message_pieces[i].strip()
|
||||||
|
|
||||||
if (len(message_pieces) == 1):
|
if len(message_pieces) == 1:
|
||||||
payload = {
|
payload = {"value": message_pieces[0], "auth_token": auth_token}
|
||||||
"value": message_pieces[0],
|
elif len(message_pieces) == 2:
|
||||||
"auth_token": auth_token
|
if message_pieces[1].isdigit():
|
||||||
}
|
|
||||||
elif (len(message_pieces) == 2):
|
|
||||||
if (message_pieces[1].isdigit()):
|
|
||||||
payload = {
|
payload = {
|
||||||
"daystamp": message_pieces[0],
|
"daystamp": message_pieces[0],
|
||||||
"value": message_pieces[1],
|
"value": message_pieces[1],
|
||||||
"auth_token": auth_token
|
"auth_token": auth_token,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
payload = {
|
payload = {
|
||||||
"value": message_pieces[0],
|
"value": message_pieces[0],
|
||||||
"comment": message_pieces[1],
|
"comment": message_pieces[1],
|
||||||
"auth_token": auth_token
|
"auth_token": auth_token,
|
||||||
}
|
}
|
||||||
elif (len(message_pieces) == 3):
|
elif len(message_pieces) == 3:
|
||||||
payload = {
|
payload = {
|
||||||
"daystamp": message_pieces[0],
|
"daystamp": message_pieces[0],
|
||||||
"value": message_pieces[1],
|
"value": message_pieces[1],
|
||||||
"comment": message_pieces[2],
|
"comment": message_pieces[2],
|
||||||
"auth_token": auth_token
|
"auth_token": auth_token,
|
||||||
}
|
}
|
||||||
elif (len(message_pieces) > 3):
|
elif len(message_pieces) > 3:
|
||||||
return "Make sure you follow the syntax.\n You can take a look \
|
return "Make sure you follow the syntax.\n You can take a look \
|
||||||
at syntax by: @mention-botname help"
|
at syntax by: @mention-botname help"
|
||||||
|
|
||||||
|
@ -63,13 +63,17 @@ at syntax by: @mention-botname help"
|
||||||
r = requests.post(url, json=payload)
|
r = requests.post(url, json=payload)
|
||||||
|
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
if r.status_code == 401: # Handles case of invalid key and missing key
|
if r.status_code == 401: # Handles case of invalid key and missing key
|
||||||
return "Error. Check your key!"
|
return "Error. Check your key!"
|
||||||
else:
|
else:
|
||||||
return "Error occured : {}".format(r.status_code) # Occures in case of unprocessable entity
|
return "Error occured : {}".format(
|
||||||
|
r.status_code
|
||||||
|
) # Occures in case of unprocessable entity
|
||||||
else:
|
else:
|
||||||
datapoint_link = "https://www.beeminder.com/{}/{}".format(username, goalname)
|
datapoint_link = "https://www.beeminder.com/{}/{}".format(username, goalname)
|
||||||
return "[Datapoint]({}) created.".format(datapoint_link) # Handles the case of successful datapoint creation
|
return "[Datapoint]({}) created.".format(
|
||||||
|
datapoint_link
|
||||||
|
) # Handles the case of successful datapoint creation
|
||||||
except ConnectionError as e:
|
except ConnectionError as e:
|
||||||
logging.exception(str(e))
|
logging.exception(str(e))
|
||||||
return "Uh-Oh, couldn't process the request \
|
return "Uh-Oh, couldn't process the request \
|
||||||
|
@ -87,7 +91,9 @@ class BeeminderHandler:
|
||||||
# Check for valid auth_token
|
# Check for valid auth_token
|
||||||
auth_token = self.config_info['auth_token']
|
auth_token = self.config_info['auth_token']
|
||||||
try:
|
try:
|
||||||
r = requests.get("https://www.beeminder.com/api/v1/users/me.json", params={'auth_token': auth_token})
|
r = requests.get(
|
||||||
|
"https://www.beeminder.com/api/v1/users/me.json", params={'auth_token': auth_token}
|
||||||
|
)
|
||||||
if r.status_code == 401:
|
if r.status_code == 401:
|
||||||
bot_handler.quit('Invalid key!')
|
bot_handler.quit('Invalid key!')
|
||||||
except ConnectionError as e:
|
except ConnectionError as e:
|
||||||
|
@ -100,4 +106,5 @@ class BeeminderHandler:
|
||||||
response = get_beeminder_response(message['content'], self.config_info)
|
response = get_beeminder_response(message['content'], self.config_info)
|
||||||
bot_handler.send_reply(message, response)
|
bot_handler.send_reply(message, response)
|
||||||
|
|
||||||
|
|
||||||
handler_class = BeeminderHandler
|
handler_class = BeeminderHandler
|
||||||
|
|
|
@ -7,11 +7,7 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests, StubBotHandler, get_b
|
||||||
|
|
||||||
class TestBeeminderBot(BotTestCase, DefaultTests):
|
class TestBeeminderBot(BotTestCase, DefaultTests):
|
||||||
bot_name = "beeminder"
|
bot_name = "beeminder"
|
||||||
normal_config = {
|
normal_config = {"auth_token": "XXXXXX", "username": "aaron", "goalname": "goal"}
|
||||||
"auth_token": "XXXXXX",
|
|
||||||
"username": "aaron",
|
|
||||||
"goalname": "goal"
|
|
||||||
}
|
|
||||||
|
|
||||||
help_message = '''
|
help_message = '''
|
||||||
You can add datapoints towards your beeminder goals \
|
You can add datapoints towards your beeminder goals \
|
||||||
|
@ -24,88 +20,94 @@ following the syntax shown below :smile:.\n \
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def test_bot_responds_to_empty_message(self) -> None:
|
def test_bot_responds_to_empty_message(self) -> None:
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
|
||||||
self.mock_http_conversation('test_valid_auth_token'):
|
'test_valid_auth_token'
|
||||||
|
):
|
||||||
self.verify_reply('', self.help_message)
|
self.verify_reply('', self.help_message)
|
||||||
|
|
||||||
def test_help_message(self) -> None:
|
def test_help_message(self) -> None:
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
|
||||||
self.mock_http_conversation('test_valid_auth_token'):
|
'test_valid_auth_token'
|
||||||
|
):
|
||||||
self.verify_reply('help', self.help_message)
|
self.verify_reply('help', self.help_message)
|
||||||
|
|
||||||
def test_message_with_daystamp_and_value(self) -> None:
|
def test_message_with_daystamp_and_value(self) -> None:
|
||||||
bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.'
|
bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.'
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
|
||||||
self.mock_http_conversation('test_valid_auth_token'), \
|
'test_valid_auth_token'
|
||||||
self.mock_http_conversation('test_message_with_daystamp_and_value'):
|
), self.mock_http_conversation('test_message_with_daystamp_and_value'):
|
||||||
self.verify_reply('20180602, 2', bot_response)
|
self.verify_reply('20180602, 2', bot_response)
|
||||||
|
|
||||||
def test_message_with_value_and_comment(self) -> None:
|
def test_message_with_value_and_comment(self) -> None:
|
||||||
bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.'
|
bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.'
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
|
||||||
self.mock_http_conversation('test_valid_auth_token'), \
|
'test_valid_auth_token'
|
||||||
self.mock_http_conversation('test_message_with_value_and_comment'):
|
), self.mock_http_conversation('test_message_with_value_and_comment'):
|
||||||
self.verify_reply('2, hi there!', bot_response)
|
self.verify_reply('2, hi there!', bot_response)
|
||||||
|
|
||||||
def test_message_with_daystamp_and_value_and_comment(self) -> None:
|
def test_message_with_daystamp_and_value_and_comment(self) -> None:
|
||||||
bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.'
|
bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.'
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
|
||||||
self.mock_http_conversation('test_valid_auth_token'), \
|
'test_valid_auth_token'
|
||||||
self.mock_http_conversation('test_message_with_daystamp_and_value_and_comment'):
|
), self.mock_http_conversation('test_message_with_daystamp_and_value_and_comment'):
|
||||||
self.verify_reply('20180602, 2, hi there!', bot_response)
|
self.verify_reply('20180602, 2, hi there!', bot_response)
|
||||||
|
|
||||||
def test_syntax_error(self) -> None:
|
def test_syntax_error(self) -> None:
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
|
||||||
self.mock_http_conversation('test_valid_auth_token'):
|
'test_valid_auth_token'
|
||||||
|
):
|
||||||
bot_response = "Make sure you follow the syntax.\n You can take a look \
|
bot_response = "Make sure you follow the syntax.\n You can take a look \
|
||||||
at syntax by: @mention-botname help"
|
at syntax by: @mention-botname help"
|
||||||
self.verify_reply("20180303, 50, comment, redundant comment", bot_response)
|
self.verify_reply("20180303, 50, comment, redundant comment", bot_response)
|
||||||
|
|
||||||
def test_connection_error_when_handle_message(self) -> None:
|
def test_connection_error_when_handle_message(self) -> None:
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
|
||||||
self.mock_http_conversation('test_valid_auth_token'), \
|
'test_valid_auth_token'
|
||||||
patch('requests.post', side_effect=ConnectionError()), \
|
), patch('requests.post', side_effect=ConnectionError()), patch('logging.exception'):
|
||||||
patch('logging.exception'):
|
self.verify_reply(
|
||||||
self.verify_reply('?$!', 'Uh-Oh, couldn\'t process the request \
|
'?$!',
|
||||||
right now.\nPlease try again later')
|
'Uh-Oh, couldn\'t process the request \
|
||||||
|
right now.\nPlease try again later',
|
||||||
|
)
|
||||||
|
|
||||||
def test_invalid_when_handle_message(self) -> None:
|
def test_invalid_when_handle_message(self) -> None:
|
||||||
get_bot_message_handler(self.bot_name)
|
get_bot_message_handler(self.bot_name)
|
||||||
StubBotHandler()
|
StubBotHandler()
|
||||||
|
|
||||||
with self.mock_config_info({'auth_token': 'someInvalidKey',
|
with self.mock_config_info(
|
||||||
'username': 'aaron',
|
{'auth_token': 'someInvalidKey', 'username': 'aaron', 'goalname': 'goal'}
|
||||||
'goalname': 'goal'}), \
|
), patch('requests.get', side_effect=ConnectionError()), self.mock_http_conversation(
|
||||||
patch('requests.get', side_effect=ConnectionError()), \
|
'test_invalid_when_handle_message'
|
||||||
self.mock_http_conversation('test_invalid_when_handle_message'), \
|
), patch(
|
||||||
patch('logging.exception'):
|
'logging.exception'
|
||||||
|
):
|
||||||
self.verify_reply('5', 'Error. Check your key!')
|
self.verify_reply('5', 'Error. Check your key!')
|
||||||
|
|
||||||
def test_error(self) -> None:
|
def test_error(self) -> None:
|
||||||
bot_request = 'notNumber'
|
bot_request = 'notNumber'
|
||||||
bot_response = "Error occured : 422"
|
bot_response = "Error occured : 422"
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
|
||||||
self.mock_http_conversation('test_valid_auth_token'), \
|
'test_valid_auth_token'
|
||||||
self.mock_http_conversation('test_error'):
|
), self.mock_http_conversation('test_error'):
|
||||||
self.verify_reply(bot_request, bot_response)
|
self.verify_reply(bot_request, bot_response)
|
||||||
|
|
||||||
def test_invalid_when_initialize(self) -> None:
|
def test_invalid_when_initialize(self) -> None:
|
||||||
bot = get_bot_message_handler(self.bot_name)
|
bot = get_bot_message_handler(self.bot_name)
|
||||||
bot_handler = StubBotHandler()
|
bot_handler = StubBotHandler()
|
||||||
|
|
||||||
with self.mock_config_info({'auth_token': 'someInvalidKey',
|
with self.mock_config_info(
|
||||||
'username': 'aaron',
|
{'auth_token': 'someInvalidKey', 'username': 'aaron', 'goalname': 'goal'}
|
||||||
'goalname': 'goal'}), \
|
), self.mock_http_conversation('test_invalid_when_initialize'), self.assertRaises(
|
||||||
self.mock_http_conversation('test_invalid_when_initialize'), \
|
bot_handler.BotQuitException
|
||||||
self.assertRaises(bot_handler.BotQuitException):
|
):
|
||||||
bot.initialize(bot_handler)
|
bot.initialize(bot_handler)
|
||||||
|
|
||||||
def test_connection_error_during_initialize(self) -> None:
|
def test_connection_error_during_initialize(self) -> None:
|
||||||
bot = get_bot_message_handler(self.bot_name)
|
bot = get_bot_message_handler(self.bot_name)
|
||||||
bot_handler = StubBotHandler()
|
bot_handler = StubBotHandler()
|
||||||
|
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), patch(
|
||||||
patch('requests.get', side_effect=ConnectionError()), \
|
'requests.get', side_effect=ConnectionError()
|
||||||
patch('logging.exception') as mock_logging:
|
), patch('logging.exception') as mock_logging:
|
||||||
bot.initialize(bot_handler)
|
bot.initialize(bot_handler)
|
||||||
self.assertTrue(mock_logging.called)
|
self.assertTrue(mock_logging.called)
|
||||||
|
|
|
@ -8,12 +8,11 @@ import chess.uci
|
||||||
from zulip_bots.lib import BotHandler
|
from zulip_bots.lib import BotHandler
|
||||||
|
|
||||||
START_REGEX = re.compile('start with other user$')
|
START_REGEX = re.compile('start with other user$')
|
||||||
START_COMPUTER_REGEX = re.compile(
|
START_COMPUTER_REGEX = re.compile('start as (?P<user_color>white|black) with computer')
|
||||||
'start as (?P<user_color>white|black) with computer'
|
|
||||||
)
|
|
||||||
MOVE_REGEX = re.compile('do (?P<move_san>.+)$')
|
MOVE_REGEX = re.compile('do (?P<move_san>.+)$')
|
||||||
RESIGN_REGEX = re.compile('resign$')
|
RESIGN_REGEX = re.compile('resign$')
|
||||||
|
|
||||||
|
|
||||||
class ChessHandler:
|
class ChessHandler:
|
||||||
def usage(self) -> str:
|
def usage(self) -> str:
|
||||||
return (
|
return (
|
||||||
|
@ -29,20 +28,14 @@ class ChessHandler:
|
||||||
self.config_info = bot_handler.get_config_info('chess')
|
self.config_info = bot_handler.get_config_info('chess')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.engine = chess.uci.popen_engine(
|
self.engine = chess.uci.popen_engine(self.config_info['stockfish_location'])
|
||||||
self.config_info['stockfish_location']
|
|
||||||
)
|
|
||||||
self.engine.uci()
|
self.engine.uci()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# It is helpful to allow for fake Stockfish locations if the bot
|
# It is helpful to allow for fake Stockfish locations if the bot
|
||||||
# runner is testing or knows they won't be using an engine.
|
# runner is testing or knows they won't be using an engine.
|
||||||
print('That Stockfish doesn\'t exist. Continuing.')
|
print('That Stockfish doesn\'t exist. Continuing.')
|
||||||
|
|
||||||
def handle_message(
|
def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None:
|
||||||
self,
|
|
||||||
message: Dict[str, str],
|
|
||||||
bot_handler: BotHandler
|
|
||||||
) -> None:
|
|
||||||
content = message['content']
|
content = message['content']
|
||||||
|
|
||||||
if content == '':
|
if content == '':
|
||||||
|
@ -60,7 +53,8 @@ class ChessHandler:
|
||||||
if bot_handler.storage.contains('is_with_computer'):
|
if bot_handler.storage.contains('is_with_computer'):
|
||||||
is_with_computer = (
|
is_with_computer = (
|
||||||
# `bot_handler`'s `storage` only accepts `str` values.
|
# `bot_handler`'s `storage` only accepts `str` values.
|
||||||
bot_handler.storage.get('is_with_computer') == str(True)
|
bot_handler.storage.get('is_with_computer')
|
||||||
|
== str(True)
|
||||||
)
|
)
|
||||||
|
|
||||||
if bot_handler.storage.contains('last_fen'):
|
if bot_handler.storage.contains('last_fen'):
|
||||||
|
@ -70,31 +64,17 @@ class ChessHandler:
|
||||||
self.start(message, bot_handler)
|
self.start(message, bot_handler)
|
||||||
elif start_computer_regex_match:
|
elif start_computer_regex_match:
|
||||||
self.start_computer(
|
self.start_computer(
|
||||||
message,
|
message, bot_handler, start_computer_regex_match.group('user_color') == 'white'
|
||||||
bot_handler,
|
|
||||||
start_computer_regex_match.group('user_color') == 'white'
|
|
||||||
)
|
)
|
||||||
elif move_regex_match:
|
elif move_regex_match:
|
||||||
if is_with_computer:
|
if is_with_computer:
|
||||||
self.move_computer(
|
self.move_computer(
|
||||||
message,
|
message, bot_handler, last_fen, move_regex_match.group('move_san')
|
||||||
bot_handler,
|
|
||||||
last_fen,
|
|
||||||
move_regex_match.group('move_san')
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.move(
|
self.move(message, bot_handler, last_fen, move_regex_match.group('move_san'))
|
||||||
message,
|
|
||||||
bot_handler,
|
|
||||||
last_fen,
|
|
||||||
move_regex_match.group('move_san')
|
|
||||||
)
|
|
||||||
elif resign_regex_match:
|
elif resign_regex_match:
|
||||||
self.resign(
|
self.resign(message, bot_handler, last_fen)
|
||||||
message,
|
|
||||||
bot_handler,
|
|
||||||
last_fen
|
|
||||||
)
|
|
||||||
|
|
||||||
def start(self, message: Dict[str, str], bot_handler: BotHandler) -> None:
|
def start(self, message: Dict[str, str], bot_handler: BotHandler) -> None:
|
||||||
"""Starts a game with another user, with the current user as white.
|
"""Starts a game with another user, with the current user as white.
|
||||||
|
@ -105,10 +85,7 @@ class ChessHandler:
|
||||||
- bot_handler: The Zulip Bots bot handler object.
|
- bot_handler: The Zulip Bots bot handler object.
|
||||||
"""
|
"""
|
||||||
new_board = chess.Board()
|
new_board = chess.Board()
|
||||||
bot_handler.send_reply(
|
bot_handler.send_reply(message, make_start_reponse(new_board))
|
||||||
message,
|
|
||||||
make_start_reponse(new_board)
|
|
||||||
)
|
|
||||||
|
|
||||||
# `bot_handler`'s `storage` only accepts `str` values.
|
# `bot_handler`'s `storage` only accepts `str` values.
|
||||||
bot_handler.storage.put('is_with_computer', str(False))
|
bot_handler.storage.put('is_with_computer', str(False))
|
||||||
|
@ -116,10 +93,7 @@ class ChessHandler:
|
||||||
bot_handler.storage.put('last_fen', new_board.fen())
|
bot_handler.storage.put('last_fen', new_board.fen())
|
||||||
|
|
||||||
def start_computer(
|
def start_computer(
|
||||||
self,
|
self, message: Dict[str, str], bot_handler: BotHandler, is_white_user: bool
|
||||||
message: Dict[str, str],
|
|
||||||
bot_handler: BotHandler,
|
|
||||||
is_white_user: bool
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Starts a game with the computer. Replies to the bot handler.
|
"""Starts a game with the computer. Replies to the bot handler.
|
||||||
|
|
||||||
|
@ -135,10 +109,7 @@ class ChessHandler:
|
||||||
new_board = chess.Board()
|
new_board = chess.Board()
|
||||||
|
|
||||||
if is_white_user:
|
if is_white_user:
|
||||||
bot_handler.send_reply(
|
bot_handler.send_reply(message, make_start_computer_reponse(new_board))
|
||||||
message,
|
|
||||||
make_start_computer_reponse(new_board)
|
|
||||||
)
|
|
||||||
|
|
||||||
# `bot_handler`'s `storage` only accepts `str` values.
|
# `bot_handler`'s `storage` only accepts `str` values.
|
||||||
bot_handler.storage.put('is_with_computer', str(True))
|
bot_handler.storage.put('is_with_computer', str(True))
|
||||||
|
@ -152,10 +123,7 @@ class ChessHandler:
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate_board(
|
def validate_board(
|
||||||
self,
|
self, message: Dict[str, str], bot_handler: BotHandler, fen: str
|
||||||
message: Dict[str, str],
|
|
||||||
bot_handler: BotHandler,
|
|
||||||
fen: str
|
|
||||||
) -> Optional[chess.Board]:
|
) -> Optional[chess.Board]:
|
||||||
"""Validates a board based on its FEN string. Replies to the bot
|
"""Validates a board based on its FEN string. Replies to the bot
|
||||||
handler if there is an error with the board.
|
handler if there is an error with the board.
|
||||||
|
@ -171,10 +139,7 @@ class ChessHandler:
|
||||||
try:
|
try:
|
||||||
last_board = chess.Board(fen)
|
last_board = chess.Board(fen)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
bot_handler.send_reply(
|
bot_handler.send_reply(message, make_copied_wrong_response())
|
||||||
message,
|
|
||||||
make_copied_wrong_response()
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return last_board
|
return last_board
|
||||||
|
@ -185,7 +150,7 @@ class ChessHandler:
|
||||||
bot_handler: BotHandler,
|
bot_handler: BotHandler,
|
||||||
last_board: chess.Board,
|
last_board: chess.Board,
|
||||||
move_san: str,
|
move_san: str,
|
||||||
is_computer: object
|
is_computer: object,
|
||||||
) -> Optional[chess.Move]:
|
) -> Optional[chess.Move]:
|
||||||
"""Validates a move based on its SAN string and the current board.
|
"""Validates a move based on its SAN string and the current board.
|
||||||
Replies to the bot handler if there is an error with the move.
|
Replies to the bot handler if there is an error with the move.
|
||||||
|
@ -205,29 +170,17 @@ class ChessHandler:
|
||||||
try:
|
try:
|
||||||
move = last_board.parse_san(move_san)
|
move = last_board.parse_san(move_san)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
bot_handler.send_reply(
|
bot_handler.send_reply(message, make_not_legal_response(last_board, move_san))
|
||||||
message,
|
|
||||||
make_not_legal_response(
|
|
||||||
last_board,
|
|
||||||
move_san
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if move not in last_board.legal_moves:
|
if move not in last_board.legal_moves:
|
||||||
bot_handler.send_reply(
|
bot_handler.send_reply(message, make_not_legal_response(last_board, move_san))
|
||||||
message,
|
|
||||||
make_not_legal_response(last_board, move_san)
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return move
|
return move
|
||||||
|
|
||||||
def check_game_over(
|
def check_game_over(
|
||||||
self,
|
self, message: Dict[str, str], bot_handler: BotHandler, new_board: chess.Board
|
||||||
message: Dict[str, str],
|
|
||||||
bot_handler: BotHandler,
|
|
||||||
new_board: chess.Board
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Checks if a game is over due to
|
"""Checks if a game is over due to
|
||||||
- checkmate,
|
- checkmate,
|
||||||
|
@ -254,38 +207,24 @@ class ChessHandler:
|
||||||
game_over_output = ''
|
game_over_output = ''
|
||||||
|
|
||||||
if new_board.is_checkmate():
|
if new_board.is_checkmate():
|
||||||
game_over_output = make_loss_response(
|
game_over_output = make_loss_response(new_board, 'was checkmated')
|
||||||
new_board,
|
|
||||||
'was checkmated'
|
|
||||||
)
|
|
||||||
elif new_board.is_stalemate():
|
elif new_board.is_stalemate():
|
||||||
game_over_output = make_draw_response('stalemate')
|
game_over_output = make_draw_response('stalemate')
|
||||||
elif new_board.is_insufficient_material():
|
elif new_board.is_insufficient_material():
|
||||||
game_over_output = make_draw_response(
|
game_over_output = make_draw_response('insufficient material')
|
||||||
'insufficient material'
|
|
||||||
)
|
|
||||||
elif new_board.can_claim_fifty_moves():
|
elif new_board.can_claim_fifty_moves():
|
||||||
game_over_output = make_draw_response(
|
game_over_output = make_draw_response('50 moves without a capture or pawn move')
|
||||||
'50 moves without a capture or pawn move'
|
|
||||||
)
|
|
||||||
elif new_board.can_claim_threefold_repetition():
|
elif new_board.can_claim_threefold_repetition():
|
||||||
game_over_output = make_draw_response('3-fold repetition')
|
game_over_output = make_draw_response('3-fold repetition')
|
||||||
|
|
||||||
bot_handler.send_reply(
|
bot_handler.send_reply(message, game_over_output)
|
||||||
message,
|
|
||||||
game_over_output
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def move(
|
def move(
|
||||||
self,
|
self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str, move_san: str
|
||||||
message: Dict[str, str],
|
|
||||||
bot_handler: BotHandler,
|
|
||||||
last_fen: str,
|
|
||||||
move_san: str
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Makes a move for a user in a game with another user. Replies to
|
"""Makes a move for a user in a game with another user. Replies to
|
||||||
the bot handler.
|
the bot handler.
|
||||||
|
@ -301,13 +240,7 @@ class ChessHandler:
|
||||||
if not last_board:
|
if not last_board:
|
||||||
return
|
return
|
||||||
|
|
||||||
move = self.validate_move(
|
move = self.validate_move(message, bot_handler, last_board, move_san, False)
|
||||||
message,
|
|
||||||
bot_handler,
|
|
||||||
last_board,
|
|
||||||
move_san,
|
|
||||||
False
|
|
||||||
)
|
|
||||||
|
|
||||||
if not move:
|
if not move:
|
||||||
return
|
return
|
||||||
|
@ -318,19 +251,12 @@ class ChessHandler:
|
||||||
if self.check_game_over(message, bot_handler, new_board):
|
if self.check_game_over(message, bot_handler, new_board):
|
||||||
return
|
return
|
||||||
|
|
||||||
bot_handler.send_reply(
|
bot_handler.send_reply(message, make_move_reponse(last_board, new_board, move))
|
||||||
message,
|
|
||||||
make_move_reponse(last_board, new_board, move)
|
|
||||||
)
|
|
||||||
|
|
||||||
bot_handler.storage.put('last_fen', new_board.fen())
|
bot_handler.storage.put('last_fen', new_board.fen())
|
||||||
|
|
||||||
def move_computer(
|
def move_computer(
|
||||||
self,
|
self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str, move_san: str
|
||||||
message: Dict[str, str],
|
|
||||||
bot_handler: BotHandler,
|
|
||||||
last_fen: str,
|
|
||||||
move_san: str
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Preforms a move for a user in a game with the computer and then
|
"""Preforms a move for a user in a game with the computer and then
|
||||||
makes the computer's move. Replies to the bot handler. Unlike `move`,
|
makes the computer's move. Replies to the bot handler. Unlike `move`,
|
||||||
|
@ -350,13 +276,7 @@ class ChessHandler:
|
||||||
if not last_board:
|
if not last_board:
|
||||||
return
|
return
|
||||||
|
|
||||||
move = self.validate_move(
|
move = self.validate_move(message, bot_handler, last_board, move_san, True)
|
||||||
message,
|
|
||||||
bot_handler,
|
|
||||||
last_board,
|
|
||||||
move_san,
|
|
||||||
True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not move:
|
if not move:
|
||||||
return
|
return
|
||||||
|
@ -367,40 +287,22 @@ class ChessHandler:
|
||||||
if self.check_game_over(message, bot_handler, new_board):
|
if self.check_game_over(message, bot_handler, new_board):
|
||||||
return
|
return
|
||||||
|
|
||||||
computer_move = calculate_computer_move(
|
computer_move = calculate_computer_move(new_board, self.engine)
|
||||||
new_board,
|
|
||||||
self.engine
|
|
||||||
)
|
|
||||||
|
|
||||||
new_board_after_computer_move = copy.copy(new_board)
|
new_board_after_computer_move = copy.copy(new_board)
|
||||||
new_board_after_computer_move.push(computer_move)
|
new_board_after_computer_move.push(computer_move)
|
||||||
|
|
||||||
if self.check_game_over(
|
if self.check_game_over(message, bot_handler, new_board_after_computer_move):
|
||||||
message,
|
|
||||||
bot_handler,
|
|
||||||
new_board_after_computer_move
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
bot_handler.send_reply(
|
bot_handler.send_reply(
|
||||||
message,
|
message, make_move_reponse(new_board, new_board_after_computer_move, computer_move)
|
||||||
make_move_reponse(
|
|
||||||
new_board,
|
|
||||||
new_board_after_computer_move,
|
|
||||||
computer_move
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
bot_handler.storage.put(
|
bot_handler.storage.put('last_fen', new_board_after_computer_move.fen())
|
||||||
'last_fen',
|
|
||||||
new_board_after_computer_move.fen()
|
|
||||||
)
|
|
||||||
|
|
||||||
def move_computer_first(
|
def move_computer_first(
|
||||||
self,
|
self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str
|
||||||
message: Dict[str, str],
|
|
||||||
bot_handler: BotHandler,
|
|
||||||
last_fen: str
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Preforms a move for the computer without having the user go first in
|
"""Preforms a move for the computer without having the user go first in
|
||||||
a game with the computer. Replies to the bot handler. Like
|
a game with the computer. Replies to the bot handler. Like
|
||||||
|
@ -415,44 +317,24 @@ class ChessHandler:
|
||||||
"""
|
"""
|
||||||
last_board = self.validate_board(message, bot_handler, last_fen)
|
last_board = self.validate_board(message, bot_handler, last_fen)
|
||||||
|
|
||||||
computer_move = calculate_computer_move(
|
computer_move = calculate_computer_move(last_board, self.engine)
|
||||||
last_board,
|
|
||||||
self.engine
|
|
||||||
)
|
|
||||||
|
|
||||||
new_board_after_computer_move = copy.copy(last_board) # type: chess.Board
|
new_board_after_computer_move = copy.copy(last_board) # type: chess.Board
|
||||||
new_board_after_computer_move.push(computer_move)
|
new_board_after_computer_move.push(computer_move)
|
||||||
|
|
||||||
if self.check_game_over(
|
if self.check_game_over(message, bot_handler, new_board_after_computer_move):
|
||||||
message,
|
|
||||||
bot_handler,
|
|
||||||
new_board_after_computer_move
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
bot_handler.send_reply(
|
bot_handler.send_reply(
|
||||||
message,
|
message, make_move_reponse(last_board, new_board_after_computer_move, computer_move)
|
||||||
make_move_reponse(
|
|
||||||
last_board,
|
|
||||||
new_board_after_computer_move,
|
|
||||||
computer_move
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
bot_handler.storage.put(
|
bot_handler.storage.put('last_fen', new_board_after_computer_move.fen())
|
||||||
'last_fen',
|
|
||||||
new_board_after_computer_move.fen()
|
|
||||||
)
|
|
||||||
|
|
||||||
# `bot_handler`'s `storage` only accepts `str` values.
|
# `bot_handler`'s `storage` only accepts `str` values.
|
||||||
bot_handler.storage.put('is_with_computer', str(True))
|
bot_handler.storage.put('is_with_computer', str(True))
|
||||||
|
|
||||||
def resign(
|
def resign(self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str) -> None:
|
||||||
self,
|
|
||||||
message: Dict[str, str],
|
|
||||||
bot_handler: BotHandler,
|
|
||||||
last_fen: str
|
|
||||||
) -> None:
|
|
||||||
"""Resigns the game for the current player.
|
"""Resigns the game for the current player.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
@ -465,13 +347,12 @@ class ChessHandler:
|
||||||
if not last_board:
|
if not last_board:
|
||||||
return
|
return
|
||||||
|
|
||||||
bot_handler.send_reply(
|
bot_handler.send_reply(message, make_loss_response(last_board, 'resigned'))
|
||||||
message,
|
|
||||||
make_loss_response(last_board, 'resigned')
|
|
||||||
)
|
|
||||||
|
|
||||||
handler_class = ChessHandler
|
handler_class = ChessHandler
|
||||||
|
|
||||||
|
|
||||||
def calculate_computer_move(board: chess.Board, engine: Any) -> chess.Move:
|
def calculate_computer_move(board: chess.Board, engine: Any) -> chess.Move:
|
||||||
"""Calculates the computer's move.
|
"""Calculates the computer's move.
|
||||||
|
|
||||||
|
@ -485,6 +366,7 @@ def calculate_computer_move(board: chess.Board, engine: Any) -> chess.Move:
|
||||||
best_move_and_ponder_move = engine.go(movetime=(3000))
|
best_move_and_ponder_move = engine.go(movetime=(3000))
|
||||||
return best_move_and_ponder_move[0]
|
return best_move_and_ponder_move[0]
|
||||||
|
|
||||||
|
|
||||||
def make_draw_response(reason: str) -> str:
|
def make_draw_response(reason: str) -> str:
|
||||||
"""Makes a response string for a draw.
|
"""Makes a response string for a draw.
|
||||||
|
|
||||||
|
@ -496,6 +378,7 @@ def make_draw_response(reason: str) -> str:
|
||||||
"""
|
"""
|
||||||
return 'It\'s a draw because of {}!'.format(reason)
|
return 'It\'s a draw because of {}!'.format(reason)
|
||||||
|
|
||||||
|
|
||||||
def make_loss_response(board: chess.Board, reason: str) -> str:
|
def make_loss_response(board: chess.Board, reason: str) -> str:
|
||||||
"""Makes a response string for a loss (or win).
|
"""Makes a response string for a loss (or win).
|
||||||
|
|
||||||
|
@ -506,16 +389,14 @@ def make_loss_response(board: chess.Board, reason: str) -> str:
|
||||||
|
|
||||||
Returns: The loss response string.
|
Returns: The loss response string.
|
||||||
"""
|
"""
|
||||||
return (
|
return ('*{}* {}. **{}** wins!\n\n' '{}').format(
|
||||||
'*{}* {}. **{}** wins!\n\n'
|
|
||||||
'{}'
|
|
||||||
).format(
|
|
||||||
'White' if board.turn else 'Black',
|
'White' if board.turn else 'Black',
|
||||||
reason,
|
reason,
|
||||||
'Black' if board.turn else 'White',
|
'Black' if board.turn else 'White',
|
||||||
make_str(board, board.turn)
|
make_str(board, board.turn),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_not_legal_response(board: chess.Board, move_san: str) -> str:
|
def make_not_legal_response(board: chess.Board, move_san: str) -> str:
|
||||||
"""Makes a response string for a not-legal move.
|
"""Makes a response string for a not-legal move.
|
||||||
|
|
||||||
|
@ -525,17 +406,11 @@ def make_not_legal_response(board: chess.Board, move_san: str) -> str:
|
||||||
|
|
||||||
Returns: The not-legal-move response string.
|
Returns: The not-legal-move response string.
|
||||||
"""
|
"""
|
||||||
return (
|
return ('Sorry, the move *{}* isn\'t legal.\n\n' '{}' '\n\n\n' '{}').format(
|
||||||
'Sorry, the move *{}* isn\'t legal.\n\n'
|
move_san, make_str(board, board.turn), make_footer()
|
||||||
'{}'
|
|
||||||
'\n\n\n'
|
|
||||||
'{}'
|
|
||||||
).format(
|
|
||||||
move_san,
|
|
||||||
make_str(board, board.turn),
|
|
||||||
make_footer()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_copied_wrong_response() -> str:
|
def make_copied_wrong_response() -> str:
|
||||||
"""Makes a response string for a FEN string that was copied wrong.
|
"""Makes a response string for a FEN string that was copied wrong.
|
||||||
|
|
||||||
|
@ -546,6 +421,7 @@ def make_copied_wrong_response() -> str:
|
||||||
'Please try to copy the response again from the last message!'
|
'Please try to copy the response again from the last message!'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_start_reponse(board: chess.Board) -> str:
|
def make_start_reponse(board: chess.Board) -> str:
|
||||||
"""Makes a response string for the first response of a game with another
|
"""Makes a response string for the first response of a game with another
|
||||||
user.
|
user.
|
||||||
|
@ -563,11 +439,8 @@ def make_start_reponse(board: chess.Board) -> str:
|
||||||
'Now it\'s **{}**\'s turn.'
|
'Now it\'s **{}**\'s turn.'
|
||||||
'\n\n\n'
|
'\n\n\n'
|
||||||
'{}'
|
'{}'
|
||||||
).format(
|
).format(make_str(board, True), 'white' if board.turn else 'black', make_footer())
|
||||||
make_str(board, True),
|
|
||||||
'white' if board.turn else 'black',
|
|
||||||
make_footer()
|
|
||||||
)
|
|
||||||
|
|
||||||
def make_start_computer_reponse(board: chess.Board) -> str:
|
def make_start_computer_reponse(board: chess.Board) -> str:
|
||||||
"""Makes a response string for the first response of a game with a
|
"""Makes a response string for the first response of a game with a
|
||||||
|
@ -587,17 +460,10 @@ def make_start_computer_reponse(board: chess.Board) -> str:
|
||||||
'Now it\'s **{}**\'s turn.'
|
'Now it\'s **{}**\'s turn.'
|
||||||
'\n\n\n'
|
'\n\n\n'
|
||||||
'{}'
|
'{}'
|
||||||
).format(
|
).format(make_str(board, True), 'white' if board.turn else 'black', make_footer())
|
||||||
make_str(board, True),
|
|
||||||
'white' if board.turn else 'black',
|
|
||||||
make_footer()
|
|
||||||
)
|
|
||||||
|
|
||||||
def make_move_reponse(
|
|
||||||
last_board: chess.Board,
|
def make_move_reponse(last_board: chess.Board, new_board: chess.Board, move: chess.Move) -> str:
|
||||||
new_board: chess.Board,
|
|
||||||
move: chess.Move
|
|
||||||
) -> str:
|
|
||||||
"""Makes a response string for after a move is made.
|
"""Makes a response string for after a move is made.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
@ -623,9 +489,10 @@ def make_move_reponse(
|
||||||
last_board.san(move),
|
last_board.san(move),
|
||||||
make_str(new_board, new_board.turn),
|
make_str(new_board, new_board.turn),
|
||||||
'white' if new_board.turn else 'black',
|
'white' if new_board.turn else 'black',
|
||||||
make_footer()
|
make_footer(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_footer() -> str:
|
def make_footer() -> str:
|
||||||
"""Makes a footer to be appended to the bottom of other, actionable
|
"""Makes a footer to be appended to the bottom of other, actionable
|
||||||
responses.
|
responses.
|
||||||
|
@ -637,6 +504,7 @@ def make_footer() -> str:
|
||||||
'response.*'
|
'response.*'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_str(board: chess.Board, is_white_on_bottom: bool) -> str:
|
def make_str(board: chess.Board, is_white_on_bottom: bool) -> str:
|
||||||
"""Converts a board object into a string to be used in Markdown. Backticks
|
"""Converts a board object into a string to be used in Markdown. Backticks
|
||||||
are added around the string to preserve formatting.
|
are added around the string to preserve formatting.
|
||||||
|
@ -654,14 +522,14 @@ def make_str(board: chess.Board, is_white_on_bottom: bool) -> str:
|
||||||
replaced_str = replace_with_unicode(default_str)
|
replaced_str = replace_with_unicode(default_str)
|
||||||
replaced_and_guided_str = guide_with_numbers(replaced_str)
|
replaced_and_guided_str = guide_with_numbers(replaced_str)
|
||||||
properly_flipped_str = (
|
properly_flipped_str = (
|
||||||
replaced_and_guided_str if is_white_on_bottom
|
replaced_and_guided_str if is_white_on_bottom else replaced_and_guided_str[::-1]
|
||||||
else replaced_and_guided_str[::-1]
|
|
||||||
)
|
)
|
||||||
trimmed_str = trim_whitespace_before_newline(properly_flipped_str)
|
trimmed_str = trim_whitespace_before_newline(properly_flipped_str)
|
||||||
monospaced_str = '```\n{}\n```'.format(trimmed_str)
|
monospaced_str = '```\n{}\n```'.format(trimmed_str)
|
||||||
|
|
||||||
return monospaced_str
|
return monospaced_str
|
||||||
|
|
||||||
|
|
||||||
def guide_with_numbers(board_str: str) -> str:
|
def guide_with_numbers(board_str: str) -> str:
|
||||||
"""Adds numbers and letters on the side of a string without them made out
|
"""Adds numbers and letters on the side of a string without them made out
|
||||||
of a board.
|
of a board.
|
||||||
|
@ -702,12 +570,11 @@ def guide_with_numbers(board_str: str) -> str:
|
||||||
row_str = (' '.join(row_list) + ' 1').replace('\n ', '\n')
|
row_str = (' '.join(row_list) + ' 1').replace('\n ', '\n')
|
||||||
|
|
||||||
# a, b, c, d, e, f, g, and h are easy to add in.
|
# a, b, c, d, e, f, g, and h are easy to add in.
|
||||||
row_and_col_str = (
|
row_and_col_str = ' a b c d e f g h \n' + row_str + '\n a b c d e f g h '
|
||||||
' a b c d e f g h \n' + row_str + '\n a b c d e f g h '
|
|
||||||
)
|
|
||||||
|
|
||||||
return row_and_col_str
|
return row_and_col_str
|
||||||
|
|
||||||
|
|
||||||
def replace_with_unicode(board_str: str) -> str:
|
def replace_with_unicode(board_str: str) -> str:
|
||||||
"""Replaces the default characters in a board object's string output with
|
"""Replaces the default characters in a board object's string output with
|
||||||
Unicode chess characters, e.g., '♖' instead of 'R.'
|
Unicode chess characters, e.g., '♖' instead of 'R.'
|
||||||
|
@ -737,6 +604,7 @@ def replace_with_unicode(board_str: str) -> str:
|
||||||
|
|
||||||
return replaced_str
|
return replaced_str
|
||||||
|
|
||||||
|
|
||||||
def trim_whitespace_before_newline(str_to_trim: str) -> str:
|
def trim_whitespace_before_newline(str_to_trim: str) -> str:
|
||||||
"""Removes any spaces before a newline in a string.
|
"""Removes any spaces before a newline in a string.
|
||||||
|
|
||||||
|
|
|
@ -114,9 +114,11 @@ To make your next move, respond to Chess Bot with
|
||||||
|
|
||||||
def test_main(self) -> None:
|
def test_main(self) -> None:
|
||||||
with self.mock_config_info({'stockfish_location': '/foo/bar'}):
|
with self.mock_config_info({'stockfish_location': '/foo/bar'}):
|
||||||
self.verify_dialog([
|
self.verify_dialog(
|
||||||
('start with other user', self.START_RESPONSE),
|
[
|
||||||
('do e4', self.DO_E4_RESPONSE),
|
('start with other user', self.START_RESPONSE),
|
||||||
('do Ke4', self.DO_KE4_RESPONSE),
|
('do e4', self.DO_E4_RESPONSE),
|
||||||
('resign', self.RESIGN_RESPONSE),
|
('do Ke4', self.DO_KE4_RESPONSE),
|
||||||
])
|
('resign', self.RESIGN_RESPONSE),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
|
@ -46,8 +46,10 @@ class ConnectFourBotHandler(GameAdapter):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
game_name = 'Connect Four'
|
game_name = 'Connect Four'
|
||||||
bot_name = 'connect_four'
|
bot_name = 'connect_four'
|
||||||
move_help_message = '* To make your move during a game, type\n' \
|
move_help_message = (
|
||||||
'```move <column-number>``` or ```<column-number>```'
|
'* To make your move during a game, type\n'
|
||||||
|
'```move <column-number>``` or ```<column-number>```'
|
||||||
|
)
|
||||||
move_regex = '(move ([1-7])$)|(([1-7])$)'
|
move_regex = '(move ([1-7])$)|(([1-7])$)'
|
||||||
model = ConnectFourModel
|
model = ConnectFourModel
|
||||||
gameMessageHandler = ConnectFourMessageHandler
|
gameMessageHandler = ConnectFourMessageHandler
|
||||||
|
@ -61,7 +63,7 @@ class ConnectFourBotHandler(GameAdapter):
|
||||||
model,
|
model,
|
||||||
gameMessageHandler,
|
gameMessageHandler,
|
||||||
rules,
|
rules,
|
||||||
max_players=2
|
max_players=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ class ConnectFourModel:
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
]
|
]
|
||||||
|
|
||||||
self.current_board = self.blank_board
|
self.current_board = self.blank_board
|
||||||
|
@ -27,10 +27,7 @@ class ConnectFourModel:
|
||||||
|
|
||||||
def get_column(self, col):
|
def get_column(self, col):
|
||||||
# We use this in tests.
|
# We use this in tests.
|
||||||
return [
|
return [self.current_board[i][col] for i in range(6)]
|
||||||
self.current_board[i][col]
|
|
||||||
for i in range(6)
|
|
||||||
]
|
|
||||||
|
|
||||||
def validate_move(self, column_number):
|
def validate_move(self, column_number):
|
||||||
if column_number < 0 or column_number > 6:
|
if column_number < 0 or column_number > 6:
|
||||||
|
@ -76,8 +73,12 @@ class ConnectFourModel:
|
||||||
|
|
||||||
for row in range(0, 6):
|
for row in range(0, 6):
|
||||||
for column in range(0, 4):
|
for column in range(0, 4):
|
||||||
horizontal_sum = board[row][column] + board[row][column + 1] + \
|
horizontal_sum = (
|
||||||
board[row][column + 2] + board[row][column + 3]
|
board[row][column]
|
||||||
|
+ board[row][column + 1]
|
||||||
|
+ board[row][column + 2]
|
||||||
|
+ board[row][column + 3]
|
||||||
|
)
|
||||||
if horizontal_sum == -4:
|
if horizontal_sum == -4:
|
||||||
return -1
|
return -1
|
||||||
elif horizontal_sum == 4:
|
elif horizontal_sum == 4:
|
||||||
|
@ -90,8 +91,12 @@ class ConnectFourModel:
|
||||||
|
|
||||||
for row in range(0, 3):
|
for row in range(0, 3):
|
||||||
for column in range(0, 7):
|
for column in range(0, 7):
|
||||||
vertical_sum = board[row][column] + board[row + 1][column] + \
|
vertical_sum = (
|
||||||
board[row + 2][column] + board[row + 3][column]
|
board[row][column]
|
||||||
|
+ board[row + 1][column]
|
||||||
|
+ board[row + 2][column]
|
||||||
|
+ board[row + 3][column]
|
||||||
|
)
|
||||||
if vertical_sum == -4:
|
if vertical_sum == -4:
|
||||||
return -1
|
return -1
|
||||||
elif vertical_sum == 4:
|
elif vertical_sum == 4:
|
||||||
|
@ -106,8 +111,12 @@ class ConnectFourModel:
|
||||||
# Major Diagonl Sum
|
# Major Diagonl Sum
|
||||||
for row in range(0, 3):
|
for row in range(0, 3):
|
||||||
for column in range(0, 4):
|
for column in range(0, 4):
|
||||||
major_diagonal_sum = board[row][column] + board[row + 1][column + 1] + \
|
major_diagonal_sum = (
|
||||||
board[row + 2][column + 2] + board[row + 3][column + 3]
|
board[row][column]
|
||||||
|
+ board[row + 1][column + 1]
|
||||||
|
+ board[row + 2][column + 2]
|
||||||
|
+ board[row + 3][column + 3]
|
||||||
|
)
|
||||||
if major_diagonal_sum == -4:
|
if major_diagonal_sum == -4:
|
||||||
return -1
|
return -1
|
||||||
elif major_diagonal_sum == 4:
|
elif major_diagonal_sum == 4:
|
||||||
|
@ -116,8 +125,12 @@ class ConnectFourModel:
|
||||||
# Minor Diagonal Sum
|
# Minor Diagonal Sum
|
||||||
for row in range(3, 6):
|
for row in range(3, 6):
|
||||||
for column in range(0, 4):
|
for column in range(0, 4):
|
||||||
minor_diagonal_sum = board[row][column] + board[row - 1][column + 1] + \
|
minor_diagonal_sum = (
|
||||||
board[row - 2][column + 2] + board[row - 3][column + 3]
|
board[row][column]
|
||||||
|
+ board[row - 1][column + 1]
|
||||||
|
+ board[row - 2][column + 2]
|
||||||
|
+ board[row - 3][column + 3]
|
||||||
|
)
|
||||||
if minor_diagonal_sum == -4:
|
if minor_diagonal_sum == -4:
|
||||||
return -1
|
return -1
|
||||||
elif minor_diagonal_sum == 4:
|
elif minor_diagonal_sum == 4:
|
||||||
|
@ -132,9 +145,11 @@ class ConnectFourModel:
|
||||||
if top_row_multiple != 0:
|
if top_row_multiple != 0:
|
||||||
return 'draw'
|
return 'draw'
|
||||||
|
|
||||||
winner = get_horizontal_wins(self.current_board) + \
|
winner = (
|
||||||
get_vertical_wins(self.current_board) + \
|
get_horizontal_wins(self.current_board)
|
||||||
get_diagonal_wins(self.current_board)
|
+ get_vertical_wins(self.current_board)
|
||||||
|
+ get_diagonal_wins(self.current_board)
|
||||||
|
)
|
||||||
|
|
||||||
if winner == 1:
|
if winner == 1:
|
||||||
return first_player
|
return first_player
|
||||||
|
|
|
@ -9,20 +9,19 @@ class TestConnectFourBot(BotTestCase, DefaultTests):
|
||||||
bot_name = 'connect_four'
|
bot_name = 'connect_four'
|
||||||
|
|
||||||
def make_request_message(
|
def make_request_message(
|
||||||
self,
|
self, content: str, user: str = 'foo@example.com', user_name: str = 'foo'
|
||||||
content: str,
|
|
||||||
user: str = 'foo@example.com',
|
|
||||||
user_name: str = 'foo'
|
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
message = dict(
|
message = dict(sender_email=user, content=content, sender_full_name=user_name)
|
||||||
sender_email=user,
|
|
||||||
content=content,
|
|
||||||
sender_full_name=user_name
|
|
||||||
)
|
|
||||||
return message
|
return message
|
||||||
|
|
||||||
# Function that serves similar purpose to BotTestCase.verify_dialog, but allows for multiple responses to be handled
|
# Function that serves similar purpose to BotTestCase.verify_dialog, but allows for multiple responses to be handled
|
||||||
def verify_response(self, request: str, expected_response: str, response_number: int, user: str = 'foo@example.com') -> None:
|
def verify_response(
|
||||||
|
self,
|
||||||
|
request: str,
|
||||||
|
expected_response: str,
|
||||||
|
response_number: int,
|
||||||
|
user: str = 'foo@example.com',
|
||||||
|
) -> None:
|
||||||
'''
|
'''
|
||||||
This function serves a similar purpose
|
This function serves a similar purpose
|
||||||
to BotTestCase.verify_dialog, but allows
|
to BotTestCase.verify_dialog, but allows
|
||||||
|
@ -36,11 +35,7 @@ class TestConnectFourBot(BotTestCase, DefaultTests):
|
||||||
|
|
||||||
bot.handle_message(message, bot_handler)
|
bot.handle_message(message, bot_handler)
|
||||||
|
|
||||||
responses = [
|
responses = [message for (method, message) in bot_handler.transcript]
|
||||||
message
|
|
||||||
for (method, message)
|
|
||||||
in bot_handler.transcript
|
|
||||||
]
|
|
||||||
|
|
||||||
first_response = responses[response_number]
|
first_response = responses[response_number]
|
||||||
self.assertEqual(expected_response, first_response['content'])
|
self.assertEqual(expected_response, first_response['content'])
|
||||||
|
@ -73,7 +68,9 @@ class TestConnectFourBot(BotTestCase, DefaultTests):
|
||||||
self.verify_response('help', self.help_message(), 0)
|
self.verify_response('help', self.help_message(), 0)
|
||||||
|
|
||||||
def test_game_message_handler_responses(self) -> None:
|
def test_game_message_handler_responses(self) -> None:
|
||||||
board = ':one: :two: :three: :four: :five: :six: :seven:\n\n' + '\
|
board = (
|
||||||
|
':one: :two: :three: :four: :five: :six: :seven:\n\n'
|
||||||
|
+ '\
|
||||||
:white_circle: :white_circle: :white_circle: :white_circle: \
|
:white_circle: :white_circle: :white_circle: :white_circle: \
|
||||||
:white_circle: :white_circle: :white_circle: \n\n\
|
:white_circle: :white_circle: :white_circle: \n\n\
|
||||||
:white_circle: :white_circle: :white_circle: :white_circle: \
|
:white_circle: :white_circle: :white_circle: :white_circle: \
|
||||||
|
@ -86,16 +83,18 @@ class TestConnectFourBot(BotTestCase, DefaultTests):
|
||||||
:white_circle: :white_circle: \n\n\
|
:white_circle: :white_circle: \n\n\
|
||||||
:blue_circle: :red_circle: :white_circle: :white_circle: :white_circle: \
|
:blue_circle: :red_circle: :white_circle: :white_circle: :white_circle: \
|
||||||
:white_circle: :white_circle: '
|
:white_circle: :white_circle: '
|
||||||
|
)
|
||||||
bot, bot_handler = self._get_handlers()
|
bot, bot_handler = self._get_handlers()
|
||||||
self.assertEqual(bot.gameMessageHandler.parse_board(
|
self.assertEqual(bot.gameMessageHandler.parse_board(self.almost_win_board), board)
|
||||||
self.almost_win_board), board)
|
self.assertEqual(bot.gameMessageHandler.get_player_color(1), ':red_circle:')
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
bot.gameMessageHandler.get_player_color(1), ':red_circle:')
|
bot.gameMessageHandler.alert_move_message('foo', 'move 6'), 'foo moved in column 6'
|
||||||
self.assertEqual(bot.gameMessageHandler.alert_move_message(
|
)
|
||||||
'foo', 'move 6'), 'foo moved in column 6')
|
self.assertEqual(
|
||||||
self.assertEqual(bot.gameMessageHandler.game_start_message(
|
bot.gameMessageHandler.game_start_message(),
|
||||||
), 'Type `move <column-number>` or `<column-number>` to place a token.\n\
|
'Type `move <column-number>` or `<column-number>` to place a token.\n\
|
||||||
The first player to get 4 in a row wins!\n Good Luck!')
|
The first player to get 4 in a row wins!\n Good Luck!',
|
||||||
|
)
|
||||||
|
|
||||||
blank_board = [
|
blank_board = [
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
@ -103,7 +102,8 @@ The first player to get 4 in a row wins!\n Good Luck!')
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]]
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
]
|
||||||
|
|
||||||
almost_win_board = [
|
almost_win_board = [
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
@ -111,7 +111,8 @@ The first player to get 4 in a row wins!\n Good Luck!')
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[1, -1, 0, 0, 0, 0, 0],
|
[1, -1, 0, 0, 0, 0, 0],
|
||||||
[1, -1, 0, 0, 0, 0, 0],
|
[1, -1, 0, 0, 0, 0, 0],
|
||||||
[1, -1, 0, 0, 0, 0, 0]]
|
[1, -1, 0, 0, 0, 0, 0],
|
||||||
|
]
|
||||||
|
|
||||||
almost_draw_board = [
|
almost_draw_board = [
|
||||||
[1, -1, 1, -1, 1, -1, 0],
|
[1, -1, 1, -1, 1, -1, 0],
|
||||||
|
@ -119,13 +120,12 @@ The first player to get 4 in a row wins!\n Good Luck!')
|
||||||
[0, 0, 0, 0, 0, 0, -1],
|
[0, 0, 0, 0, 0, 0, -1],
|
||||||
[0, 0, 0, 0, 0, 0, 1],
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
[0, 0, 0, 0, 0, 0, -1],
|
[0, 0, 0, 0, 0, 0, -1],
|
||||||
[0, 0, 0, 0, 0, 0, 1]]
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
|
]
|
||||||
|
|
||||||
def test_connect_four_logic(self) -> None:
|
def test_connect_four_logic(self) -> None:
|
||||||
def confirmAvailableMoves(
|
def confirmAvailableMoves(
|
||||||
good_moves: List[int],
|
good_moves: List[int], bad_moves: List[int], board: List[List[int]]
|
||||||
bad_moves: List[int],
|
|
||||||
board: List[List[int]]
|
|
||||||
) -> None:
|
) -> None:
|
||||||
connectFourModel.update_board(board)
|
connectFourModel.update_board(board)
|
||||||
|
|
||||||
|
@ -139,18 +139,16 @@ The first player to get 4 in a row wins!\n Good Luck!')
|
||||||
column_number: int,
|
column_number: int,
|
||||||
token_number: int,
|
token_number: int,
|
||||||
initial_board: List[List[int]],
|
initial_board: List[List[int]],
|
||||||
final_board: List[List[int]]
|
final_board: List[List[int]],
|
||||||
) -> None:
|
) -> None:
|
||||||
connectFourModel.update_board(initial_board)
|
connectFourModel.update_board(initial_board)
|
||||||
test_board = connectFourModel.make_move(
|
test_board = connectFourModel.make_move('move ' + str(column_number), token_number)
|
||||||
'move ' + str(column_number), token_number)
|
|
||||||
|
|
||||||
self.assertEqual(test_board, final_board)
|
self.assertEqual(test_board, final_board)
|
||||||
|
|
||||||
def confirmGameOver(board: List[List[int]], result: str) -> None:
|
def confirmGameOver(board: List[List[int]], result: str) -> None:
|
||||||
connectFourModel.update_board(board)
|
connectFourModel.update_board(board)
|
||||||
game_over = connectFourModel.determine_game_over(
|
game_over = connectFourModel.determine_game_over(['first_player', 'second_player'])
|
||||||
['first_player', 'second_player'])
|
|
||||||
|
|
||||||
self.assertEqual(game_over, result)
|
self.assertEqual(game_over, result)
|
||||||
|
|
||||||
|
@ -170,7 +168,8 @@ The first player to get 4 in a row wins!\n Good Luck!')
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]]
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
]
|
||||||
|
|
||||||
full_board = [
|
full_board = [
|
||||||
[1, 1, 1, 1, 1, 1, 1],
|
[1, 1, 1, 1, 1, 1, 1],
|
||||||
|
@ -178,7 +177,8 @@ The first player to get 4 in a row wins!\n Good Luck!')
|
||||||
[1, 1, 1, 1, 1, 1, 1],
|
[1, 1, 1, 1, 1, 1, 1],
|
||||||
[1, 1, 1, 1, 1, 1, 1],
|
[1, 1, 1, 1, 1, 1, 1],
|
||||||
[1, 1, 1, 1, 1, 1, 1],
|
[1, 1, 1, 1, 1, 1, 1],
|
||||||
[1, 1, 1, 1, 1, 1, 1]]
|
[1, 1, 1, 1, 1, 1, 1],
|
||||||
|
]
|
||||||
|
|
||||||
single_column_board = [
|
single_column_board = [
|
||||||
[1, 1, 1, 0, 1, 1, 1],
|
[1, 1, 1, 0, 1, 1, 1],
|
||||||
|
@ -186,7 +186,8 @@ The first player to get 4 in a row wins!\n Good Luck!')
|
||||||
[1, 1, 1, 0, 1, 1, 1],
|
[1, 1, 1, 0, 1, 1, 1],
|
||||||
[1, 1, 1, 0, 1, 1, 1],
|
[1, 1, 1, 0, 1, 1, 1],
|
||||||
[1, 1, 1, 0, 1, 1, 1],
|
[1, 1, 1, 0, 1, 1, 1],
|
||||||
[1, 1, 1, 0, 1, 1, 1]]
|
[1, 1, 1, 0, 1, 1, 1],
|
||||||
|
]
|
||||||
|
|
||||||
diagonal_board = [
|
diagonal_board = [
|
||||||
[0, 0, 0, 0, 0, 0, 1],
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
|
@ -194,7 +195,8 @@ The first player to get 4 in a row wins!\n Good Luck!')
|
||||||
[0, 0, 0, 0, 1, 1, 1],
|
[0, 0, 0, 0, 1, 1, 1],
|
||||||
[0, 0, 0, 1, 1, 1, 1],
|
[0, 0, 0, 1, 1, 1, 1],
|
||||||
[0, 0, 1, 1, 1, 1, 1],
|
[0, 0, 1, 1, 1, 1, 1],
|
||||||
[0, 1, 1, 1, 1, 1, 1]]
|
[0, 1, 1, 1, 1, 1, 1],
|
||||||
|
]
|
||||||
|
|
||||||
# Winning Board Setups
|
# Winning Board Setups
|
||||||
# Each array if consists of two arrays:
|
# Each array if consists of two arrays:
|
||||||
|
@ -204,190 +206,222 @@ The first player to get 4 in a row wins!\n Good Luck!')
|
||||||
# for simplicity (random -1 and 1s could be added)
|
# for simplicity (random -1 and 1s could be added)
|
||||||
horizontal_win_boards = [
|
horizontal_win_boards = [
|
||||||
[
|
[
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[1, 1, 1, 1, 0, 0, 0]],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[1, 1, 1, 1, 0, 0, 0],
|
||||||
[[0, 0, 0, 1, 1, 1, 1],
|
],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 1, 1, 1, 1],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, 1, 1, 1, 1, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]]
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 1, 1, 1, 1, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[-1, -1, -1, -1, 0, 0, 0]],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[-1, -1, -1, -1, 0, 0, 0],
|
||||||
[[0, 0, 0, -1, -1, -1, -1],
|
],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, -1, -1, -1, -1],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, -1, -1, -1, -1, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]]
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
]
|
[0, 0, -1, -1, -1, -1, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
],
|
||||||
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
vertical_win_boards = [
|
vertical_win_boards = [
|
||||||
[
|
[
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[1, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[1, 0, 0, 0, 0, 0, 0],
|
[1, 0, 0, 0, 0, 0, 0],
|
||||||
[1, 0, 0, 0, 0, 0, 0],
|
[1, 0, 0, 0, 0, 0, 0],
|
||||||
[1, 0, 0, 0, 0, 0, 0]],
|
[1, 0, 0, 0, 0, 0, 0],
|
||||||
|
[1, 0, 0, 0, 0, 0, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, 1],
|
],
|
||||||
[0, 0, 0, 0, 0, 0, 1],
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 1],
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
[0, 0, 0, 0, 0, 0, 1],
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
[0, 0, 0, 0, 0, 0, 0]],
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 1, 0, 0, 0],
|
],
|
||||||
[0, 0, 0, 1, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 1, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 1, 0, 0, 0],
|
[0, 0, 0, 1, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]]
|
[0, 0, 0, 1, 0, 0, 0],
|
||||||
|
[0, 0, 0, 1, 0, 0, 0],
|
||||||
|
[0, 0, 0, 1, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[-1, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[-1, 0, 0, 0, 0, 0, 0],
|
[-1, 0, 0, 0, 0, 0, 0],
|
||||||
[-1, 0, 0, 0, 0, 0, 0],
|
[-1, 0, 0, 0, 0, 0, 0],
|
||||||
[-1, 0, 0, 0, 0, 0, 0]],
|
[-1, 0, 0, 0, 0, 0, 0],
|
||||||
|
[-1, 0, 0, 0, 0, 0, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, -1],
|
],
|
||||||
[0, 0, 0, 0, 0, 0, -1],
|
[
|
||||||
[0, 0, 0, 0, 0, 0, -1],
|
[0, 0, 0, 0, 0, 0, -1],
|
||||||
[0, 0, 0, 0, 0, 0, -1],
|
[0, 0, 0, 0, 0, 0, -1],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, -1],
|
||||||
[0, 0, 0, 0, 0, 0, 0]],
|
[0, 0, 0, 0, 0, 0, -1],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, -1, 0, 0, 0],
|
],
|
||||||
[0, 0, 0, -1, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, -1, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, -1, 0, 0, 0],
|
[0, 0, 0, -1, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]]
|
[0, 0, 0, -1, 0, 0, 0],
|
||||||
]
|
[0, 0, 0, -1, 0, 0, 0],
|
||||||
|
[0, 0, 0, -1, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
],
|
||||||
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
major_diagonal_win_boards = [
|
major_diagonal_win_boards = [
|
||||||
[
|
[
|
||||||
[[1, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 1, 0, 0, 0, 0, 0],
|
[1, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 1, 0, 0, 0, 0],
|
[0, 1, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 1, 0, 0, 0],
|
[0, 0, 1, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 1, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 1, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 1, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 1, 0],
|
[0, 0, 0, 1, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 1]],
|
[0, 0, 0, 0, 1, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 1, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
[0, 0, 1, 0, 0, 0, 0],
|
],
|
||||||
[0, 0, 0, 1, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 0, 1, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 1, 0],
|
[0, 0, 1, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]]
|
[0, 0, 0, 1, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 1, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 1, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[[-1, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, -1, 0, 0, 0, 0, 0],
|
[-1, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, -1, 0, 0, 0, 0],
|
[0, -1, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, -1, 0, 0, 0],
|
[0, 0, -1, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, -1, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, -1, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, -1, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, -1, 0],
|
[0, 0, 0, -1, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, -1]],
|
[0, 0, 0, 0, -1, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, -1, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, -1],
|
||||||
[0, 0, -1, 0, 0, 0, 0],
|
],
|
||||||
[0, 0, 0, -1, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 0, -1, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, -1, 0],
|
[0, 0, -1, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]]
|
[0, 0, 0, -1, 0, 0, 0],
|
||||||
]
|
[0, 0, 0, 0, -1, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, -1, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
],
|
||||||
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
minor_diagonal_win_boards = [
|
minor_diagonal_win_boards = [
|
||||||
[
|
[
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 1, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 1, 0, 0, 0, 0],
|
[0, 0, 0, 1, 0, 0, 0],
|
||||||
[0, 1, 0, 0, 0, 0, 0],
|
[0, 0, 1, 0, 0, 0, 0],
|
||||||
[1, 0, 0, 0, 0, 0, 0]],
|
[0, 1, 0, 0, 0, 0, 0],
|
||||||
|
[1, 0, 0, 0, 0, 0, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, 1],
|
],
|
||||||
[0, 0, 0, 0, 0, 1, 0],
|
[
|
||||||
[0, 0, 0, 0, 1, 0, 0],
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
[0, 0, 0, 1, 0, 0, 0],
|
[0, 0, 0, 0, 0, 1, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 1, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]],
|
[0, 0, 0, 1, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, 1, 0, 0],
|
],
|
||||||
[0, 0, 0, 1, 0, 0, 0],
|
[
|
||||||
[0, 0, 1, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 1, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 1, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]]
|
[0, 0, 0, 1, 0, 0, 0],
|
||||||
|
[0, 0, 1, 0, 0, 0, 0],
|
||||||
|
[0, 1, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, -1, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, -1, 0, 0, 0, 0],
|
[0, 0, 0, -1, 0, 0, 0],
|
||||||
[0, -1, 0, 0, 0, 0, 0],
|
[0, 0, -1, 0, 0, 0, 0],
|
||||||
[-1, 0, 0, 0, 0, 0, 0]],
|
[0, -1, 0, 0, 0, 0, 0],
|
||||||
|
[-1, 0, 0, 0, 0, 0, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, -1],
|
],
|
||||||
[0, 0, 0, 0, 0, -1, 0],
|
[
|
||||||
[0, 0, 0, 0, -1, 0, 0],
|
[0, 0, 0, 0, 0, 0, -1],
|
||||||
[0, 0, 0, -1, 0, 0, 0],
|
[0, 0, 0, 0, 0, -1, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, -1, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]],
|
[0, 0, 0, -1, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 0, 0, -1, 0, 0],
|
],
|
||||||
[0, 0, 0, -1, 0, 0, 0],
|
[
|
||||||
[0, 0, -1, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, -1, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, -1, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 0, 0]]
|
[0, 0, 0, -1, 0, 0, 0],
|
||||||
]
|
[0, 0, -1, 0, 0, 0, 0],
|
||||||
|
[0, -1, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
],
|
||||||
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
# Test Move Validation Logic
|
# Test Move Validation Logic
|
||||||
|
@ -397,8 +431,7 @@ The first player to get 4 in a row wins!\n Good Luck!')
|
||||||
|
|
||||||
# Test Available Move Logic
|
# Test Available Move Logic
|
||||||
connectFourModel.update_board(blank_board)
|
connectFourModel.update_board(blank_board)
|
||||||
self.assertEqual(connectFourModel.available_moves(),
|
self.assertEqual(connectFourModel.available_moves(), [0, 1, 2, 3, 4, 5, 6])
|
||||||
[0, 1, 2, 3, 4, 5, 6])
|
|
||||||
|
|
||||||
connectFourModel.update_board(single_column_board)
|
connectFourModel.update_board(single_column_board)
|
||||||
self.assertEqual(connectFourModel.available_moves(), [3])
|
self.assertEqual(connectFourModel.available_moves(), [3])
|
||||||
|
@ -407,69 +440,117 @@ The first player to get 4 in a row wins!\n Good Luck!')
|
||||||
self.assertEqual(connectFourModel.available_moves(), [])
|
self.assertEqual(connectFourModel.available_moves(), [])
|
||||||
|
|
||||||
# Test Move Logic
|
# Test Move Logic
|
||||||
confirmMove(1, 0, blank_board,
|
confirmMove(
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
1,
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
0,
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
blank_board,
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[1, 0, 0, 0, 0, 0, 0]])
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[1, 0, 0, 0, 0, 0, 0],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
confirmMove(1, 1, blank_board,
|
confirmMove(
|
||||||
[[0, 0, 0, 0, 0, 0, 0],
|
1,
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
1,
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
blank_board,
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
[-1, 0, 0, 0, 0, 0, 0]])
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0, 0, 0, 0],
|
||||||
|
[-1, 0, 0, 0, 0, 0, 0],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
confirmMove(1, 0, diagonal_board,
|
confirmMove(
|
||||||
[[0, 0, 0, 0, 0, 0, 1],
|
1,
|
||||||
[0, 0, 0, 0, 0, 1, 1],
|
0,
|
||||||
[0, 0, 0, 0, 1, 1, 1],
|
diagonal_board,
|
||||||
[0, 0, 0, 1, 1, 1, 1],
|
[
|
||||||
[0, 0, 1, 1, 1, 1, 1],
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
[1, 1, 1, 1, 1, 1, 1]])
|
[0, 0, 0, 0, 0, 1, 1],
|
||||||
|
[0, 0, 0, 0, 1, 1, 1],
|
||||||
|
[0, 0, 0, 1, 1, 1, 1],
|
||||||
|
[0, 0, 1, 1, 1, 1, 1],
|
||||||
|
[1, 1, 1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
confirmMove(2, 0, diagonal_board,
|
confirmMove(
|
||||||
[[0, 0, 0, 0, 0, 0, 1],
|
2,
|
||||||
[0, 0, 0, 0, 0, 1, 1],
|
0,
|
||||||
[0, 0, 0, 0, 1, 1, 1],
|
diagonal_board,
|
||||||
[0, 0, 0, 1, 1, 1, 1],
|
[
|
||||||
[0, 1, 1, 1, 1, 1, 1],
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
[0, 1, 1, 1, 1, 1, 1]])
|
[0, 0, 0, 0, 0, 1, 1],
|
||||||
|
[0, 0, 0, 0, 1, 1, 1],
|
||||||
|
[0, 0, 0, 1, 1, 1, 1],
|
||||||
|
[0, 1, 1, 1, 1, 1, 1],
|
||||||
|
[0, 1, 1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
confirmMove(3, 0, diagonal_board,
|
confirmMove(
|
||||||
[[0, 0, 0, 0, 0, 0, 1],
|
3,
|
||||||
[0, 0, 0, 0, 0, 1, 1],
|
0,
|
||||||
[0, 0, 0, 0, 1, 1, 1],
|
diagonal_board,
|
||||||
[0, 0, 1, 1, 1, 1, 1],
|
[
|
||||||
[0, 0, 1, 1, 1, 1, 1],
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
[0, 1, 1, 1, 1, 1, 1]])
|
[0, 0, 0, 0, 0, 1, 1],
|
||||||
|
[0, 0, 0, 0, 1, 1, 1],
|
||||||
|
[0, 0, 1, 1, 1, 1, 1],
|
||||||
|
[0, 0, 1, 1, 1, 1, 1],
|
||||||
|
[0, 1, 1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
confirmMove(4, 0, diagonal_board,
|
confirmMove(
|
||||||
[[0, 0, 0, 0, 0, 0, 1],
|
4,
|
||||||
[0, 0, 0, 0, 0, 1, 1],
|
0,
|
||||||
[0, 0, 0, 1, 1, 1, 1],
|
diagonal_board,
|
||||||
[0, 0, 0, 1, 1, 1, 1],
|
[
|
||||||
[0, 0, 1, 1, 1, 1, 1],
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
[0, 1, 1, 1, 1, 1, 1]])
|
[0, 0, 0, 0, 0, 1, 1],
|
||||||
|
[0, 0, 0, 1, 1, 1, 1],
|
||||||
|
[0, 0, 0, 1, 1, 1, 1],
|
||||||
|
[0, 0, 1, 1, 1, 1, 1],
|
||||||
|
[0, 1, 1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
confirmMove(5, 0, diagonal_board,
|
confirmMove(
|
||||||
[[0, 0, 0, 0, 0, 0, 1],
|
5,
|
||||||
[0, 0, 0, 0, 1, 1, 1],
|
0,
|
||||||
[0, 0, 0, 0, 1, 1, 1],
|
diagonal_board,
|
||||||
[0, 0, 0, 1, 1, 1, 1],
|
[
|
||||||
[0, 0, 1, 1, 1, 1, 1],
|
[0, 0, 0, 0, 0, 0, 1],
|
||||||
[0, 1, 1, 1, 1, 1, 1]])
|
[0, 0, 0, 0, 1, 1, 1],
|
||||||
|
[0, 0, 0, 0, 1, 1, 1],
|
||||||
|
[0, 0, 0, 1, 1, 1, 1],
|
||||||
|
[0, 0, 1, 1, 1, 1, 1],
|
||||||
|
[0, 1, 1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
confirmMove(6, 0, diagonal_board,
|
confirmMove(
|
||||||
[[0, 0, 0, 0, 0, 1, 1],
|
6,
|
||||||
[0, 0, 0, 0, 0, 1, 1],
|
0,
|
||||||
[0, 0, 0, 0, 1, 1, 1],
|
diagonal_board,
|
||||||
[0, 0, 0, 1, 1, 1, 1],
|
[
|
||||||
[0, 0, 1, 1, 1, 1, 1],
|
[0, 0, 0, 0, 0, 1, 1],
|
||||||
[0, 1, 1, 1, 1, 1, 1]])
|
[0, 0, 0, 0, 0, 1, 1],
|
||||||
|
[0, 0, 0, 0, 1, 1, 1],
|
||||||
|
[0, 0, 0, 1, 1, 1, 1],
|
||||||
|
[0, 0, 1, 1, 1, 1, 1],
|
||||||
|
[0, 1, 1, 1, 1, 1, 1],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
# Test Game Over Logic:
|
# Test Game Over Logic:
|
||||||
confirmGameOver(blank_board, '')
|
confirmGameOver(blank_board, '')
|
||||||
|
|
|
@ -15,14 +15,17 @@ def is_float(value: Any) -> bool:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Rounds the number 'x' to 'digits' significant digits.
|
# Rounds the number 'x' to 'digits' significant digits.
|
||||||
# A normal 'round()' would round the number to an absolute amount of
|
# A normal 'round()' would round the number to an absolute amount of
|
||||||
# fractional decimals, e.g. 0.00045 would become 0.0.
|
# fractional decimals, e.g. 0.00045 would become 0.0.
|
||||||
# 'round_to()' rounds only the digits that are not 0.
|
# 'round_to()' rounds only the digits that are not 0.
|
||||||
# 0.00045 would then become 0.0005.
|
# 0.00045 would then become 0.0005.
|
||||||
|
|
||||||
|
|
||||||
def round_to(x: float, digits: int) -> float:
|
def round_to(x: float, digits: int) -> float:
|
||||||
return round(x, digits-int(floor(log10(abs(x)))))
|
return round(x, digits - int(floor(log10(abs(x)))))
|
||||||
|
|
||||||
|
|
||||||
class ConverterHandler:
|
class ConverterHandler:
|
||||||
'''
|
'''
|
||||||
|
@ -49,6 +52,7 @@ class ConverterHandler:
|
||||||
bot_response = get_bot_converter_response(message, bot_handler)
|
bot_response = get_bot_converter_response(message, bot_handler)
|
||||||
bot_handler.send_reply(message, bot_response)
|
bot_handler.send_reply(message, bot_response)
|
||||||
|
|
||||||
|
|
||||||
def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler) -> str:
|
def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler) -> str:
|
||||||
content = message['content']
|
content = message['content']
|
||||||
|
|
||||||
|
@ -78,10 +82,10 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler)
|
||||||
for key, exp in utils.PREFIXES.items():
|
for key, exp in utils.PREFIXES.items():
|
||||||
if unit_from.startswith(key):
|
if unit_from.startswith(key):
|
||||||
exponent += exp
|
exponent += exp
|
||||||
unit_from = unit_from[len(key):]
|
unit_from = unit_from[len(key) :]
|
||||||
if unit_to.startswith(key):
|
if unit_to.startswith(key):
|
||||||
exponent -= exp
|
exponent -= exp
|
||||||
unit_to = unit_to[len(key):]
|
unit_to = unit_to[len(key) :]
|
||||||
|
|
||||||
uf_to_std = utils.UNITS.get(unit_from, []) # type: List[Any]
|
uf_to_std = utils.UNITS.get(unit_from, []) # type: List[Any]
|
||||||
ut_to_std = utils.UNITS.get(unit_to, []) # type: List[Any]
|
ut_to_std = utils.UNITS.get(unit_to, []) # type: List[Any]
|
||||||
|
@ -97,8 +101,13 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler)
|
||||||
if uf_to_std[2] != ut_to_std[2]:
|
if uf_to_std[2] != ut_to_std[2]:
|
||||||
unit_from = unit_from.capitalize() if uf_to_std[2] == 'kelvin' else unit_from
|
unit_from = unit_from.capitalize() if uf_to_std[2] == 'kelvin' else unit_from
|
||||||
results.append(
|
results.append(
|
||||||
'`' + unit_to.capitalize() + '` and `' + unit_from + '`'
|
'`'
|
||||||
+ ' are not from the same category. ' + utils.QUICK_HELP
|
+ unit_to.capitalize()
|
||||||
|
+ '` and `'
|
||||||
|
+ unit_from
|
||||||
|
+ '`'
|
||||||
|
+ ' are not from the same category. '
|
||||||
|
+ utils.QUICK_HELP
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
@ -114,10 +123,11 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler)
|
||||||
number_res *= 10 ** exponent
|
number_res *= 10 ** exponent
|
||||||
number_res = round_to(number_res, 7)
|
number_res = round_to(number_res, 7)
|
||||||
|
|
||||||
results.append('{} {} = {} {}'.format(number,
|
results.append(
|
||||||
words[convert_index + 2],
|
'{} {} = {} {}'.format(
|
||||||
number_res,
|
number, words[convert_index + 2], number_res, words[convert_index + 3]
|
||||||
words[convert_index + 3]))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
results.append('Too few arguments given. ' + utils.QUICK_HELP)
|
results.append('Too few arguments given. ' + utils.QUICK_HELP)
|
||||||
|
@ -128,4 +138,5 @@ def get_bot_converter_response(message: Dict[str, str], bot_handler: BotHandler)
|
||||||
|
|
||||||
return new_content
|
return new_content
|
||||||
|
|
||||||
|
|
||||||
handler_class = ConverterHandler
|
handler_class = ConverterHandler
|
||||||
|
|
|
@ -7,25 +7,41 @@ class TestConverterBot(BotTestCase, DefaultTests):
|
||||||
|
|
||||||
def test_bot(self) -> None:
|
def test_bot(self) -> None:
|
||||||
dialog = [
|
dialog = [
|
||||||
("", 'Too few arguments given. Enter `@convert help` '
|
(
|
||||||
'for help on using the converter.\n'),
|
"",
|
||||||
("foo bar", 'Too few arguments given. Enter `@convert help` '
|
'Too few arguments given. Enter `@convert help` '
|
||||||
'for help on using the converter.\n'),
|
'for help on using the converter.\n',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"foo bar",
|
||||||
|
'Too few arguments given. Enter `@convert help` '
|
||||||
|
'for help on using the converter.\n',
|
||||||
|
),
|
||||||
("2 m cm", "2 m = 200.0 cm\n"),
|
("2 m cm", "2 m = 200.0 cm\n"),
|
||||||
("12.0 celsius fahrenheit", "12.0 celsius = 53.600054 fahrenheit\n"),
|
("12.0 celsius fahrenheit", "12.0 celsius = 53.600054 fahrenheit\n"),
|
||||||
("0.002 kilometer millimile", "0.002 kilometer = 1.2427424 millimile\n"),
|
("0.002 kilometer millimile", "0.002 kilometer = 1.2427424 millimile\n"),
|
||||||
("3 megabyte kilobit", "3 megabyte = 24576.0 kilobit\n"),
|
("3 megabyte kilobit", "3 megabyte = 24576.0 kilobit\n"),
|
||||||
("foo m cm", "`foo` is not a valid number. " + utils.QUICK_HELP + "\n"),
|
("foo m cm", "`foo` is not a valid number. " + utils.QUICK_HELP + "\n"),
|
||||||
("@convert help", "1. conversion: Too few arguments given. "
|
(
|
||||||
"Enter `@convert help` for help on using the converter.\n"
|
"@convert help",
|
||||||
"2. conversion: " + utils.HELP_MESSAGE + "\n"),
|
"1. conversion: Too few arguments given. "
|
||||||
("2 celsius kilometer", "`Meter` and `Celsius` are not from the same category. "
|
"Enter `@convert help` for help on using the converter.\n"
|
||||||
"Enter `@convert help` for help on using the converter.\n"),
|
"2. conversion: " + utils.HELP_MESSAGE + "\n",
|
||||||
("2 foo kilometer", "`foo` is not a valid unit."
|
),
|
||||||
" Enter `@convert help` for help on using the converter.\n"),
|
(
|
||||||
("2 kilometer foo", "`foo` is not a valid unit."
|
"2 celsius kilometer",
|
||||||
"Enter `@convert help` for help on using the converter.\n"),
|
"`Meter` and `Celsius` are not from the same category. "
|
||||||
|
"Enter `@convert help` for help on using the converter.\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2 foo kilometer",
|
||||||
|
"`foo` is not a valid unit."
|
||||||
|
" Enter `@convert help` for help on using the converter.\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2 kilometer foo",
|
||||||
|
"`foo` is not a valid unit."
|
||||||
|
"Enter `@convert help` for help on using the converter.\n",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
self.verify_dialog(dialog)
|
self.verify_dialog(dialog)
|
||||||
|
|
|
@ -2,145 +2,153 @@
|
||||||
# An entry consists of the unit's name, a constant number and a constant
|
# An entry consists of the unit's name, a constant number and a constant
|
||||||
# factor that need to be added and multiplied to convert the unit into
|
# factor that need to be added and multiplied to convert the unit into
|
||||||
# the base unit in the last parameter.
|
# the base unit in the last parameter.
|
||||||
UNITS = {'bit': [0, 1, 'bit'],
|
UNITS = {
|
||||||
'byte': [0, 8, 'bit'],
|
'bit': [0, 1, 'bit'],
|
||||||
'cubic-centimeter': [0, 0.000001, 'cubic-meter'],
|
'byte': [0, 8, 'bit'],
|
||||||
'cubic-decimeter': [0, 0.001, 'cubic-meter'],
|
'cubic-centimeter': [0, 0.000001, 'cubic-meter'],
|
||||||
'liter': [0, 0.001, 'cubic-meter'],
|
'cubic-decimeter': [0, 0.001, 'cubic-meter'],
|
||||||
'cubic-meter': [0, 1, 'cubic-meter'],
|
'liter': [0, 0.001, 'cubic-meter'],
|
||||||
'cubic-inch': [0, 0.000016387064, 'cubic-meter'],
|
'cubic-meter': [0, 1, 'cubic-meter'],
|
||||||
'fluid-ounce': [0, 0.000029574, 'cubic-meter'],
|
'cubic-inch': [0, 0.000016387064, 'cubic-meter'],
|
||||||
'cubic-foot': [0, 0.028316846592, 'cubic-meter'],
|
'fluid-ounce': [0, 0.000029574, 'cubic-meter'],
|
||||||
'cubic-yard': [0, 0.764554857984, 'cubic-meter'],
|
'cubic-foot': [0, 0.028316846592, 'cubic-meter'],
|
||||||
'teaspoon': [0, 0.0000049289216, 'cubic-meter'],
|
'cubic-yard': [0, 0.764554857984, 'cubic-meter'],
|
||||||
'tablespoon': [0, 0.000014787, 'cubic-meter'],
|
'teaspoon': [0, 0.0000049289216, 'cubic-meter'],
|
||||||
'cup': [0, 0.00023658823648491, 'cubic-meter'],
|
'tablespoon': [0, 0.000014787, 'cubic-meter'],
|
||||||
'gram': [0, 1, 'gram'],
|
'cup': [0, 0.00023658823648491, 'cubic-meter'],
|
||||||
'kilogram': [0, 1000, 'gram'],
|
'gram': [0, 1, 'gram'],
|
||||||
'ton': [0, 1000000, 'gram'],
|
'kilogram': [0, 1000, 'gram'],
|
||||||
'ounce': [0, 28.349523125, 'gram'],
|
'ton': [0, 1000000, 'gram'],
|
||||||
'pound': [0, 453.59237, 'gram'],
|
'ounce': [0, 28.349523125, 'gram'],
|
||||||
'kelvin': [0, 1, 'kelvin'],
|
'pound': [0, 453.59237, 'gram'],
|
||||||
'celsius': [273.15, 1, 'kelvin'],
|
'kelvin': [0, 1, 'kelvin'],
|
||||||
'fahrenheit': [255.372222, 0.555555, 'kelvin'],
|
'celsius': [273.15, 1, 'kelvin'],
|
||||||
'centimeter': [0, 0.01, 'meter'],
|
'fahrenheit': [255.372222, 0.555555, 'kelvin'],
|
||||||
'decimeter': [0, 0.1, 'meter'],
|
'centimeter': [0, 0.01, 'meter'],
|
||||||
'meter': [0, 1, 'meter'],
|
'decimeter': [0, 0.1, 'meter'],
|
||||||
'kilometer': [0, 1000, 'meter'],
|
'meter': [0, 1, 'meter'],
|
||||||
'inch': [0, 0.0254, 'meter'],
|
'kilometer': [0, 1000, 'meter'],
|
||||||
'foot': [0, 0.3048, 'meter'],
|
'inch': [0, 0.0254, 'meter'],
|
||||||
'yard': [0, 0.9144, 'meter'],
|
'foot': [0, 0.3048, 'meter'],
|
||||||
'mile': [0, 1609.344, 'meter'],
|
'yard': [0, 0.9144, 'meter'],
|
||||||
'nautical-mile': [0, 1852, 'meter'],
|
'mile': [0, 1609.344, 'meter'],
|
||||||
'square-centimeter': [0, 0.0001, 'square-meter'],
|
'nautical-mile': [0, 1852, 'meter'],
|
||||||
'square-decimeter': [0, 0.01, 'square-meter'],
|
'square-centimeter': [0, 0.0001, 'square-meter'],
|
||||||
'square-meter': [0, 1, 'square-meter'],
|
'square-decimeter': [0, 0.01, 'square-meter'],
|
||||||
'square-kilometer': [0, 1000000, 'square-meter'],
|
'square-meter': [0, 1, 'square-meter'],
|
||||||
'square-inch': [0, 0.00064516, 'square-meter'],
|
'square-kilometer': [0, 1000000, 'square-meter'],
|
||||||
'square-foot': [0, 0.09290304, 'square-meter'],
|
'square-inch': [0, 0.00064516, 'square-meter'],
|
||||||
'square-yard': [0, 0.83612736, 'square-meter'],
|
'square-foot': [0, 0.09290304, 'square-meter'],
|
||||||
'square-mile': [0, 2589988.110336, 'square-meter'],
|
'square-yard': [0, 0.83612736, 'square-meter'],
|
||||||
'are': [0, 100, 'square-meter'],
|
'square-mile': [0, 2589988.110336, 'square-meter'],
|
||||||
'hectare': [0, 10000, 'square-meter'],
|
'are': [0, 100, 'square-meter'],
|
||||||
'acre': [0, 4046.8564224, 'square-meter']}
|
'hectare': [0, 10000, 'square-meter'],
|
||||||
|
'acre': [0, 4046.8564224, 'square-meter'],
|
||||||
|
}
|
||||||
|
|
||||||
PREFIXES = {'atto': -18,
|
PREFIXES = {
|
||||||
'femto': -15,
|
'atto': -18,
|
||||||
'pico': -12,
|
'femto': -15,
|
||||||
'nano': -9,
|
'pico': -12,
|
||||||
'micro': -6,
|
'nano': -9,
|
||||||
'milli': -3,
|
'micro': -6,
|
||||||
'centi': -2,
|
'milli': -3,
|
||||||
'deci': -1,
|
'centi': -2,
|
||||||
'deca': 1,
|
'deci': -1,
|
||||||
'hecto': 2,
|
'deca': 1,
|
||||||
'kilo': 3,
|
'hecto': 2,
|
||||||
'mega': 6,
|
'kilo': 3,
|
||||||
'giga': 9,
|
'mega': 6,
|
||||||
'tera': 12,
|
'giga': 9,
|
||||||
'peta': 15,
|
'tera': 12,
|
||||||
'exa': 18}
|
'peta': 15,
|
||||||
|
'exa': 18,
|
||||||
|
}
|
||||||
|
|
||||||
ALIASES = {'a': 'are',
|
ALIASES = {
|
||||||
'ac': 'acre',
|
'a': 'are',
|
||||||
'c': 'celsius',
|
'ac': 'acre',
|
||||||
'cm': 'centimeter',
|
'c': 'celsius',
|
||||||
'cm2': 'square-centimeter',
|
'cm': 'centimeter',
|
||||||
'cm3': 'cubic-centimeter',
|
'cm2': 'square-centimeter',
|
||||||
'cm^2': 'square-centimeter',
|
'cm3': 'cubic-centimeter',
|
||||||
'cm^3': 'cubic-centimeter',
|
'cm^2': 'square-centimeter',
|
||||||
'dm': 'decimeter',
|
'cm^3': 'cubic-centimeter',
|
||||||
'dm2': 'square-decimeter',
|
'dm': 'decimeter',
|
||||||
'dm3': 'cubic-decimeter',
|
'dm2': 'square-decimeter',
|
||||||
'dm^2': 'square-decimeter',
|
'dm3': 'cubic-decimeter',
|
||||||
'dm^3': 'cubic-decimeter',
|
'dm^2': 'square-decimeter',
|
||||||
'f': 'fahrenheit',
|
'dm^3': 'cubic-decimeter',
|
||||||
'fl-oz': 'fluid-ounce',
|
'f': 'fahrenheit',
|
||||||
'ft': 'foot',
|
'fl-oz': 'fluid-ounce',
|
||||||
'ft2': 'square-foot',
|
'ft': 'foot',
|
||||||
'ft3': 'cubic-foot',
|
'ft2': 'square-foot',
|
||||||
'ft^2': 'square-foot',
|
'ft3': 'cubic-foot',
|
||||||
'ft^3': 'cubic-foot',
|
'ft^2': 'square-foot',
|
||||||
'g': 'gram',
|
'ft^3': 'cubic-foot',
|
||||||
'ha': 'hectare',
|
'g': 'gram',
|
||||||
'in': 'inch',
|
'ha': 'hectare',
|
||||||
'in2': 'square-inch',
|
'in': 'inch',
|
||||||
'in3': 'cubic-inch',
|
'in2': 'square-inch',
|
||||||
'in^2': 'square-inch',
|
'in3': 'cubic-inch',
|
||||||
'in^3': 'cubic-inch',
|
'in^2': 'square-inch',
|
||||||
'k': 'kelvin',
|
'in^3': 'cubic-inch',
|
||||||
'kg': 'kilogram',
|
'k': 'kelvin',
|
||||||
'km': 'kilometer',
|
'kg': 'kilogram',
|
||||||
'km2': 'square-kilometer',
|
'km': 'kilometer',
|
||||||
'km^2': 'square-kilometer',
|
'km2': 'square-kilometer',
|
||||||
'l': 'liter',
|
'km^2': 'square-kilometer',
|
||||||
'lb': 'pound',
|
'l': 'liter',
|
||||||
'm': 'meter',
|
'lb': 'pound',
|
||||||
'm2': 'square-meter',
|
'm': 'meter',
|
||||||
'm3': 'cubic-meter',
|
'm2': 'square-meter',
|
||||||
'm^2': 'square-meter',
|
'm3': 'cubic-meter',
|
||||||
'm^3': 'cubic-meter',
|
'm^2': 'square-meter',
|
||||||
'mi': 'mile',
|
'm^3': 'cubic-meter',
|
||||||
'mi2': 'square-mile',
|
'mi': 'mile',
|
||||||
'mi^2': 'square-mile',
|
'mi2': 'square-mile',
|
||||||
'nmi': 'nautical-mile',
|
'mi^2': 'square-mile',
|
||||||
'oz': 'ounce',
|
'nmi': 'nautical-mile',
|
||||||
't': 'ton',
|
'oz': 'ounce',
|
||||||
'tbsp': 'tablespoon',
|
't': 'ton',
|
||||||
'tsp': 'teaspoon',
|
'tbsp': 'tablespoon',
|
||||||
'y': 'yard',
|
'tsp': 'teaspoon',
|
||||||
'y2': 'square-yard',
|
'y': 'yard',
|
||||||
'y3': 'cubic-yard',
|
'y2': 'square-yard',
|
||||||
'y^2': 'square-yard',
|
'y3': 'cubic-yard',
|
||||||
'y^3': 'cubic-yard'}
|
'y^2': 'square-yard',
|
||||||
|
'y^3': 'cubic-yard',
|
||||||
|
}
|
||||||
|
|
||||||
HELP_MESSAGE = ('Converter usage:\n'
|
HELP_MESSAGE = (
|
||||||
'`@convert <number> <unit_from> <unit_to>`\n'
|
'Converter usage:\n'
|
||||||
'Converts `number` in the unit <unit_from> to '
|
'`@convert <number> <unit_from> <unit_to>`\n'
|
||||||
'the <unit_to> and prints the result\n'
|
'Converts `number` in the unit <unit_from> to '
|
||||||
'`number`: integer or floating point number, e.g. 12, 13.05, 0.002\n'
|
'the <unit_to> and prints the result\n'
|
||||||
'<unit_from> and <unit_to> are two of the following units:\n'
|
'`number`: integer or floating point number, e.g. 12, 13.05, 0.002\n'
|
||||||
'* square-centimeter (cm^2, cm2), square-decimeter (dm^2, dm2), '
|
'<unit_from> and <unit_to> are two of the following units:\n'
|
||||||
'square-meter (m^2, m2), square-kilometer (km^2, km2),'
|
'* square-centimeter (cm^2, cm2), square-decimeter (dm^2, dm2), '
|
||||||
' square-inch (in^2, in2), square-foot (ft^2, ft2), square-yard (y^2, y2), '
|
'square-meter (m^2, m2), square-kilometer (km^2, km2),'
|
||||||
' square-mile(mi^2, mi2), are (a), hectare (ha), acre (ac)\n'
|
' square-inch (in^2, in2), square-foot (ft^2, ft2), square-yard (y^2, y2), '
|
||||||
'* bit, byte\n'
|
' square-mile(mi^2, mi2), are (a), hectare (ha), acre (ac)\n'
|
||||||
'* centimeter (cm), decimeter(dm), meter (m),'
|
'* bit, byte\n'
|
||||||
' kilometer (km), inch (in), foot (ft), yard (y),'
|
'* centimeter (cm), decimeter(dm), meter (m),'
|
||||||
' mile (mi), nautical-mile (nmi)\n'
|
' kilometer (km), inch (in), foot (ft), yard (y),'
|
||||||
'* Kelvin (K), Celsius(C), Fahrenheit (F)\n'
|
' mile (mi), nautical-mile (nmi)\n'
|
||||||
'* cubic-centimeter (cm^3, cm3), cubic-decimeter (dm^3, dm3), liter (l), '
|
'* Kelvin (K), Celsius(C), Fahrenheit (F)\n'
|
||||||
'cubic-meter (m^3, m3), cubic-inch (in^3, in3), fluid-ounce (fl-oz), '
|
'* cubic-centimeter (cm^3, cm3), cubic-decimeter (dm^3, dm3), liter (l), '
|
||||||
'cubic-foot (ft^3, ft3), cubic-yard (y^3, y3)\n'
|
'cubic-meter (m^3, m3), cubic-inch (in^3, in3), fluid-ounce (fl-oz), '
|
||||||
'* gram (g), kilogram (kg), ton (t), ounce (oz), pound(lb)\n'
|
'cubic-foot (ft^3, ft3), cubic-yard (y^3, y3)\n'
|
||||||
'* (metric only, U.S. and imperial units differ slightly:) teaspoon (tsp), tablespoon (tbsp), cup\n\n\n'
|
'* gram (g), kilogram (kg), ton (t), ounce (oz), pound(lb)\n'
|
||||||
'Allowed prefixes are:\n'
|
'* (metric only, U.S. and imperial units differ slightly:) teaspoon (tsp), tablespoon (tbsp), cup\n\n\n'
|
||||||
'* atto, pico, femto, nano, micro, milli, centi, deci\n'
|
'Allowed prefixes are:\n'
|
||||||
'* deca, hecto, kilo, mega, giga, tera, peta, exa\n\n\n'
|
'* atto, pico, femto, nano, micro, milli, centi, deci\n'
|
||||||
'Usage examples:\n'
|
'* deca, hecto, kilo, mega, giga, tera, peta, exa\n\n\n'
|
||||||
'* `@convert 12 celsius fahrenheit`\n'
|
'Usage examples:\n'
|
||||||
'* `@convert 0.002 kilomile millimeter`\n'
|
'* `@convert 12 celsius fahrenheit`\n'
|
||||||
'* `@convert 31.5 square-mile ha`\n'
|
'* `@convert 0.002 kilomile millimeter`\n'
|
||||||
'* `@convert 56 g lb`\n')
|
'* `@convert 31.5 square-mile ha`\n'
|
||||||
|
'* `@convert 56 g lb`\n'
|
||||||
|
)
|
||||||
|
|
||||||
QUICK_HELP = 'Enter `@convert help` for help on using the converter.'
|
QUICK_HELP = 'Enter `@convert help` for help on using the converter.'
|
||||||
|
|
|
@ -66,7 +66,9 @@ class DefineHandler:
|
||||||
# Show definitions line by line.
|
# Show definitions line by line.
|
||||||
for d in definitions:
|
for d in definitions:
|
||||||
example = d['example'] if d['example'] else '*No example available.*'
|
example = d['example'] if d['example'] else '*No example available.*'
|
||||||
response += '\n' + '* (**{}**) {}\n {}'.format(d['type'], d['definition'], html2text.html2text(example))
|
response += '\n' + '* (**{}**) {}\n {}'.format(
|
||||||
|
d['type'], d['definition'], html2text.html2text(example)
|
||||||
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
response += self.REQUEST_ERROR_MESSAGE
|
response += self.REQUEST_ERROR_MESSAGE
|
||||||
|
@ -74,4 +76,5 @@ class DefineHandler:
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
handler_class = DefineHandler
|
handler_class = DefineHandler
|
||||||
|
|
|
@ -9,25 +9,29 @@ class TestDefineBot(BotTestCase, DefaultTests):
|
||||||
def test_bot(self) -> None:
|
def test_bot(self) -> None:
|
||||||
|
|
||||||
# Only one type(noun) of word.
|
# Only one type(noun) of word.
|
||||||
bot_response = ("**cat**:\n\n* (**noun**) a small domesticated carnivorous mammal "
|
bot_response = (
|
||||||
"with soft fur, a short snout, and retractile claws. It is widely "
|
"**cat**:\n\n* (**noun**) a small domesticated carnivorous mammal "
|
||||||
"kept as a pet or for catching mice, and many breeds have been "
|
"with soft fur, a short snout, and retractile claws. It is widely "
|
||||||
"developed.\n their pet cat\n\n")
|
"kept as a pet or for catching mice, and many breeds have been "
|
||||||
|
"developed.\n their pet cat\n\n"
|
||||||
|
)
|
||||||
with self.mock_http_conversation('test_single_type_word'):
|
with self.mock_http_conversation('test_single_type_word'):
|
||||||
self.verify_reply('cat', bot_response)
|
self.verify_reply('cat', bot_response)
|
||||||
|
|
||||||
# Multi-type word.
|
# Multi-type word.
|
||||||
bot_response = ("**help**:\n\n"
|
bot_response = (
|
||||||
"* (**verb**) make it easier or possible for (someone) to do something by offering them one's services or resources.\n"
|
"**help**:\n\n"
|
||||||
" they helped her with domestic chores\n\n\n"
|
"* (**verb**) make it easier or possible for (someone) to do something by offering them one's services or resources.\n"
|
||||||
"* (**verb**) serve someone with (food or drink).\n"
|
" they helped her with domestic chores\n\n\n"
|
||||||
" may I help you to some more meat?\n\n\n"
|
"* (**verb**) serve someone with (food or drink).\n"
|
||||||
"* (**verb**) cannot or could not avoid.\n"
|
" may I help you to some more meat?\n\n\n"
|
||||||
" he couldn't help laughing\n\n\n"
|
"* (**verb**) cannot or could not avoid.\n"
|
||||||
"* (**noun**) the action of helping someone to do something.\n"
|
" he couldn't help laughing\n\n\n"
|
||||||
" I asked for help from my neighbours\n\n\n"
|
"* (**noun**) the action of helping someone to do something.\n"
|
||||||
"* (**exclamation**) used as an appeal for urgent assistance.\n"
|
" I asked for help from my neighbours\n\n\n"
|
||||||
" Help! I'm drowning!\n\n")
|
"* (**exclamation**) used as an appeal for urgent assistance.\n"
|
||||||
|
" Help! I'm drowning!\n\n"
|
||||||
|
)
|
||||||
with self.mock_http_conversation('test_multi_type_word'):
|
with self.mock_http_conversation('test_multi_type_word'):
|
||||||
self.verify_reply('help', bot_response)
|
self.verify_reply('help', bot_response)
|
||||||
|
|
||||||
|
@ -49,8 +53,5 @@ class TestDefineBot(BotTestCase, DefaultTests):
|
||||||
self.verify_reply('', bot_response)
|
self.verify_reply('', bot_response)
|
||||||
|
|
||||||
def test_connection_error(self) -> None:
|
def test_connection_error(self) -> None:
|
||||||
with patch('requests.get', side_effect=Exception), \
|
with patch('requests.get', side_effect=Exception), patch('logging.exception'):
|
||||||
patch('logging.exception'):
|
self.verify_reply('aeroplane', '**aeroplane**:\nCould not load definition.')
|
||||||
self.verify_reply(
|
|
||||||
'aeroplane',
|
|
||||||
'**aeroplane**:\nCould not load definition.')
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ This bot will interact with dialogflow bots.
|
||||||
Simply send this bot a message, and it will respond depending on the configured bot's behaviour.
|
Simply send this bot a message, and it will respond depending on the configured bot's behaviour.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
def get_bot_result(message_content: str, config: Dict[str, str], sender_id: str) -> str:
|
def get_bot_result(message_content: str, config: Dict[str, str], sender_id: str) -> str:
|
||||||
if message_content.strip() == '' or message_content.strip() == 'help':
|
if message_content.strip() == '' or message_content.strip() == 'help':
|
||||||
return config['bot_info']
|
return config['bot_info']
|
||||||
|
@ -24,7 +25,9 @@ def get_bot_result(message_content: str, config: Dict[str, str], sender_id: str)
|
||||||
res_str = response.read().decode('utf8', 'ignore')
|
res_str = response.read().decode('utf8', 'ignore')
|
||||||
res_json = json.loads(res_str)
|
res_json = json.loads(res_str)
|
||||||
if res_json['status']['errorType'] != 'success' and 'result' not in res_json.keys():
|
if res_json['status']['errorType'] != 'success' and 'result' not in res_json.keys():
|
||||||
return 'Error {}: {}.'.format(res_json['status']['code'], res_json['status']['errorDetails'])
|
return 'Error {}: {}.'.format(
|
||||||
|
res_json['status']['code'], res_json['status']['errorDetails']
|
||||||
|
)
|
||||||
if res_json['result']['fulfillment']['speech'] == '':
|
if res_json['result']['fulfillment']['speech'] == '':
|
||||||
if 'alternateResult' in res_json.keys():
|
if 'alternateResult' in res_json.keys():
|
||||||
if res_json['alternateResult']['fulfillment']['speech'] != '':
|
if res_json['alternateResult']['fulfillment']['speech'] != '':
|
||||||
|
@ -35,6 +38,7 @@ def get_bot_result(message_content: str, config: Dict[str, str], sender_id: str)
|
||||||
logging.exception(str(e))
|
logging.exception(str(e))
|
||||||
return 'Error. {}.'.format(str(e))
|
return 'Error. {}.'.format(str(e))
|
||||||
|
|
||||||
|
|
||||||
class DialogFlowHandler:
|
class DialogFlowHandler:
|
||||||
'''
|
'''
|
||||||
This plugin allows users to easily add their own
|
This plugin allows users to easily add their own
|
||||||
|
@ -54,4 +58,5 @@ class DialogFlowHandler:
|
||||||
result = get_bot_result(message['content'], self.config_info, message['sender_id'])
|
result = get_bot_result(message['content'], self.config_info, message['sender_id'])
|
||||||
bot_handler.send_reply(message, result)
|
bot_handler.send_reply(message, result)
|
||||||
|
|
||||||
|
|
||||||
handler_class = DialogFlowHandler
|
handler_class = DialogFlowHandler
|
||||||
|
|
|
@ -6,14 +6,15 @@ from unittest.mock import patch
|
||||||
from zulip_bots.test_lib import BotTestCase, DefaultTests, read_bot_fixture_data
|
from zulip_bots.test_lib import BotTestCase, DefaultTests, read_bot_fixture_data
|
||||||
|
|
||||||
|
|
||||||
class MockHttplibRequest():
|
class MockHttplibRequest:
|
||||||
def __init__(self, response: str) -> None:
|
def __init__(self, response: str) -> None:
|
||||||
self.response = response
|
self.response = response
|
||||||
|
|
||||||
def read(self) -> ByteString:
|
def read(self) -> ByteString:
|
||||||
return json.dumps(self.response).encode()
|
return json.dumps(self.response).encode()
|
||||||
|
|
||||||
class MockTextRequest():
|
|
||||||
|
class MockTextRequest:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.session_id = ""
|
self.session_id = ""
|
||||||
self.query = ""
|
self.query = ""
|
||||||
|
@ -22,6 +23,7 @@ class MockTextRequest():
|
||||||
def getresponse(self) -> MockHttplibRequest:
|
def getresponse(self) -> MockHttplibRequest:
|
||||||
return MockHttplibRequest(self.response)
|
return MockHttplibRequest(self.response)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def mock_dialogflow(test_name: str, bot_name: str) -> Iterator[None]:
|
def mock_dialogflow(test_name: str, bot_name: str) -> Iterator[None]:
|
||||||
response_data = read_bot_fixture_data(bot_name, test_name)
|
response_data = read_bot_fixture_data(bot_name, test_name)
|
||||||
|
@ -38,12 +40,14 @@ def mock_dialogflow(test_name: str, bot_name: str) -> Iterator[None]:
|
||||||
mock_text_request.return_value = request
|
mock_text_request.return_value = request
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
class TestDialogFlowBot(BotTestCase, DefaultTests):
|
class TestDialogFlowBot(BotTestCase, DefaultTests):
|
||||||
bot_name = 'dialogflow'
|
bot_name = 'dialogflow'
|
||||||
|
|
||||||
def _test(self, test_name: str, message: str, response: str) -> None:
|
def _test(self, test_name: str, message: str, response: str) -> None:
|
||||||
with self.mock_config_info({'key': 'abcdefg', 'bot_info': 'bot info foo bar'}), \
|
with self.mock_config_info(
|
||||||
mock_dialogflow(test_name, 'dialogflow'):
|
{'key': 'abcdefg', 'bot_info': 'bot info foo bar'}
|
||||||
|
), mock_dialogflow(test_name, 'dialogflow'):
|
||||||
self.verify_reply(message, response)
|
self.verify_reply(message, response)
|
||||||
|
|
||||||
def test_normal(self) -> None:
|
def test_normal(self) -> None:
|
||||||
|
|
|
@ -7,6 +7,7 @@ from zulip_bots.lib import BotHandler
|
||||||
|
|
||||||
URL = "[{name}](https://www.dropbox.com/home{path})"
|
URL = "[{name}](https://www.dropbox.com/home{path})"
|
||||||
|
|
||||||
|
|
||||||
class DropboxHandler:
|
class DropboxHandler:
|
||||||
'''
|
'''
|
||||||
This bot allows you to easily share, search and upload files
|
This bot allows you to easily share, search and upload files
|
||||||
|
@ -28,6 +29,7 @@ class DropboxHandler:
|
||||||
msg = dbx_command(self.client, command)
|
msg = dbx_command(self.client, command)
|
||||||
bot_handler.send_reply(message, msg)
|
bot_handler.send_reply(message, msg)
|
||||||
|
|
||||||
|
|
||||||
def get_help() -> str:
|
def get_help() -> str:
|
||||||
return '''
|
return '''
|
||||||
Example commands:
|
Example commands:
|
||||||
|
@ -44,6 +46,7 @@ def get_help() -> str:
|
||||||
```
|
```
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
def get_usage_examples() -> str:
|
def get_usage_examples() -> str:
|
||||||
return '''
|
return '''
|
||||||
Usage:
|
Usage:
|
||||||
|
@ -61,15 +64,17 @@ def get_usage_examples() -> str:
|
||||||
```
|
```
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
REGEXES = dict(
|
REGEXES = dict(
|
||||||
command='(ls|mkdir|read|rm|write|search|usage|help)',
|
command='(ls|mkdir|read|rm|write|search|usage|help)',
|
||||||
path=r'(\S+)',
|
path=r'(\S+)',
|
||||||
optional_path=r'(\S*)',
|
optional_path=r'(\S*)',
|
||||||
some_text='(.+?)',
|
some_text='(.+?)',
|
||||||
folder=r'?(?:--fd (\S+))?',
|
folder=r'?(?:--fd (\S+))?',
|
||||||
max_results=r'?(?:--mr (\d+))?'
|
max_results=r'?(?:--mr (\d+))?',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_commands() -> Dict[str, Tuple[Any, List[str]]]:
|
def get_commands() -> Dict[str, Tuple[Any, List[str]]]:
|
||||||
return {
|
return {
|
||||||
'help': (dbx_help, ['command']),
|
'help': (dbx_help, ['command']),
|
||||||
|
@ -83,12 +88,13 @@ def get_commands() -> Dict[str, Tuple[Any, List[str]]]:
|
||||||
'usage': (dbx_usage, []),
|
'usage': (dbx_usage, []),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def dbx_command(client: Any, cmd: str) -> str:
|
def dbx_command(client: Any, cmd: str) -> str:
|
||||||
cmd = cmd.strip()
|
cmd = cmd.strip()
|
||||||
if cmd == 'help':
|
if cmd == 'help':
|
||||||
return get_help()
|
return get_help()
|
||||||
cmd_name = cmd.split()[0]
|
cmd_name = cmd.split()[0]
|
||||||
cmd_args = cmd[len(cmd_name):].strip()
|
cmd_args = cmd[len(cmd_name) :].strip()
|
||||||
commands = get_commands()
|
commands = get_commands()
|
||||||
if cmd_name not in commands:
|
if cmd_name not in commands:
|
||||||
return 'ERROR: unrecognized command\n' + get_help()
|
return 'ERROR: unrecognized command\n' + get_help()
|
||||||
|
@ -102,6 +108,7 @@ def dbx_command(client: Any, cmd: str) -> str:
|
||||||
else:
|
else:
|
||||||
return 'ERROR: ' + syntax_help(cmd_name)
|
return 'ERROR: ' + syntax_help(cmd_name)
|
||||||
|
|
||||||
|
|
||||||
def syntax_help(cmd_name: str) -> str:
|
def syntax_help(cmd_name: str) -> str:
|
||||||
commands = get_commands()
|
commands = get_commands()
|
||||||
f, arg_names = commands[cmd_name]
|
f, arg_names = commands[cmd_name]
|
||||||
|
@ -112,23 +119,29 @@ def syntax_help(cmd_name: str) -> str:
|
||||||
cmd = cmd_name
|
cmd = cmd_name
|
||||||
return 'syntax: {}'.format(cmd)
|
return 'syntax: {}'.format(cmd)
|
||||||
|
|
||||||
|
|
||||||
def dbx_help(client: Any, cmd_name: str) -> str:
|
def dbx_help(client: Any, cmd_name: str) -> str:
|
||||||
return syntax_help(cmd_name)
|
return syntax_help(cmd_name)
|
||||||
|
|
||||||
|
|
||||||
def dbx_usage(client: Any) -> str:
|
def dbx_usage(client: Any) -> str:
|
||||||
return get_usage_examples()
|
return get_usage_examples()
|
||||||
|
|
||||||
|
|
||||||
def dbx_mkdir(client: Any, fn: str) -> str:
|
def dbx_mkdir(client: Any, fn: str) -> str:
|
||||||
fn = '/' + fn # foo/boo -> /foo/boo
|
fn = '/' + fn # foo/boo -> /foo/boo
|
||||||
try:
|
try:
|
||||||
result = client.files_create_folder(fn)
|
result = client.files_create_folder(fn)
|
||||||
msg = "CREATED FOLDER: " + URL.format(name=result.name, path=result.path_lower)
|
msg = "CREATED FOLDER: " + URL.format(name=result.name, path=result.path_lower)
|
||||||
except Exception:
|
except Exception:
|
||||||
msg = "Please provide a correct folder path and name.\n"\
|
msg = (
|
||||||
"Usage: `mkdir <foldername>` to create a folder."
|
"Please provide a correct folder path and name.\n"
|
||||||
|
"Usage: `mkdir <foldername>` to create a folder."
|
||||||
|
)
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
def dbx_ls(client: Any, fn: str) -> str:
|
def dbx_ls(client: Any, fn: str) -> str:
|
||||||
if fn != '':
|
if fn != '':
|
||||||
fn = '/' + fn
|
fn = '/' + fn
|
||||||
|
@ -144,12 +157,15 @@ def dbx_ls(client: Any, fn: str) -> str:
|
||||||
msg = '`No files available`'
|
msg = '`No files available`'
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
msg = "Please provide a correct folder path\n"\
|
msg = (
|
||||||
"Usage: `ls <foldername>` to list folders in directory\n"\
|
"Please provide a correct folder path\n"
|
||||||
"or simply `ls` for listing folders in the root directory"
|
"Usage: `ls <foldername>` to list folders in directory\n"
|
||||||
|
"or simply `ls` for listing folders in the root directory"
|
||||||
|
)
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
def dbx_rm(client: Any, fn: str) -> str:
|
def dbx_rm(client: Any, fn: str) -> str:
|
||||||
fn = '/' + fn
|
fn = '/' + fn
|
||||||
|
|
||||||
|
@ -157,10 +173,13 @@ def dbx_rm(client: Any, fn: str) -> str:
|
||||||
result = client.files_delete(fn)
|
result = client.files_delete(fn)
|
||||||
msg = "DELETED File/Folder : " + URL.format(name=result.name, path=result.path_lower)
|
msg = "DELETED File/Folder : " + URL.format(name=result.name, path=result.path_lower)
|
||||||
except Exception:
|
except Exception:
|
||||||
msg = "Please provide a correct folder path and name.\n"\
|
msg = (
|
||||||
"Usage: `rm <foldername>` to delete a folder in root directory."
|
"Please provide a correct folder path and name.\n"
|
||||||
|
"Usage: `rm <foldername>` to delete a folder in root directory."
|
||||||
|
)
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
def dbx_write(client: Any, fn: str, content: str) -> str:
|
def dbx_write(client: Any, fn: str, content: str) -> str:
|
||||||
fn = '/' + fn
|
fn = '/' + fn
|
||||||
|
|
||||||
|
@ -172,6 +191,7 @@ def dbx_write(client: Any, fn: str, content: str) -> str:
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
def dbx_read(client: Any, fn: str) -> str:
|
def dbx_read(client: Any, fn: str) -> str:
|
||||||
fn = '/' + fn
|
fn = '/' + fn
|
||||||
|
|
||||||
|
@ -179,10 +199,13 @@ def dbx_read(client: Any, fn: str) -> str:
|
||||||
result = client.files_download(fn)
|
result = client.files_download(fn)
|
||||||
msg = "**{}** :\n{}".format(result[0].name, result[1].text)
|
msg = "**{}** :\n{}".format(result[0].name, result[1].text)
|
||||||
except Exception:
|
except Exception:
|
||||||
msg = "Please provide a correct file path\nUsage: `read <filename>` to read content of a file"
|
msg = (
|
||||||
|
"Please provide a correct file path\nUsage: `read <filename>` to read content of a file"
|
||||||
|
)
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
def dbx_search(client: Any, query: str, folder: str, max_results: str) -> str:
|
def dbx_search(client: Any, query: str, folder: str, max_results: str) -> str:
|
||||||
if folder is None:
|
if folder is None:
|
||||||
folder = ''
|
folder = ''
|
||||||
|
@ -201,17 +224,22 @@ def dbx_search(client: Any, query: str, folder: str, max_results: str) -> str:
|
||||||
msg = '\n'.join(msg_list)
|
msg = '\n'.join(msg_list)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
msg = "Usage: `search <foldername> query --mr 10 --fd <folderName>`\n"\
|
msg = (
|
||||||
"Note:`--mr <int>` is optional and is used to specify maximun results.\n"\
|
"Usage: `search <foldername> query --mr 10 --fd <folderName>`\n"
|
||||||
" `--fd <folderName>` to search in specific folder."
|
"Note:`--mr <int>` is optional and is used to specify maximun results.\n"
|
||||||
|
" `--fd <folderName>` to search in specific folder."
|
||||||
|
)
|
||||||
|
|
||||||
if msg == '':
|
if msg == '':
|
||||||
msg = "No files/folders found matching your query.\n"\
|
msg = (
|
||||||
"For file name searching, the last token is used for prefix matching"\
|
"No files/folders found matching your query.\n"
|
||||||
" (i.e. “bat c” matches “bat cave” but not “batman car”)."
|
"For file name searching, the last token is used for prefix matching"
|
||||||
|
" (i.e. “bat c” matches “bat cave” but not “batman car”)."
|
||||||
|
)
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
def dbx_share(client: Any, fn: str):
|
def dbx_share(client: Any, fn: str):
|
||||||
fn = '/' + fn
|
fn = '/' + fn
|
||||||
try:
|
try:
|
||||||
|
@ -222,4 +250,5 @@ def dbx_share(client: Any, fn: str):
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
handler_class = DropboxHandler
|
handler_class = DropboxHandler
|
||||||
|
|
|
@ -13,50 +13,49 @@ from zulip_bots.test_lib import BotTestCase, DefaultTests
|
||||||
|
|
||||||
def get_root_files_list(*args, **kwargs):
|
def get_root_files_list(*args, **kwargs):
|
||||||
return MockListFolderResult(
|
return MockListFolderResult(
|
||||||
entries = [
|
entries=[MockFileMetadata('foo', '/foo'), MockFileMetadata('boo', '/boo')], has_more=False
|
||||||
MockFileMetadata('foo', '/foo'),
|
|
||||||
MockFileMetadata('boo', '/boo')
|
|
||||||
],
|
|
||||||
has_more = False
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_folder_files_list(*args, **kwargs):
|
def get_folder_files_list(*args, **kwargs):
|
||||||
return MockListFolderResult(
|
return MockListFolderResult(
|
||||||
entries = [
|
entries=[
|
||||||
MockFileMetadata('moo', '/foo/moo'),
|
MockFileMetadata('moo', '/foo/moo'),
|
||||||
MockFileMetadata('noo', '/foo/noo'),
|
MockFileMetadata('noo', '/foo/noo'),
|
||||||
],
|
],
|
||||||
has_more = False
|
has_more=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_empty_files_list(*args, **kwargs):
|
def get_empty_files_list(*args, **kwargs):
|
||||||
return MockListFolderResult(
|
return MockListFolderResult(entries=[], has_more=False)
|
||||||
entries = [],
|
|
||||||
has_more = False
|
|
||||||
)
|
|
||||||
|
|
||||||
def create_file(*args, **kwargs):
|
def create_file(*args, **kwargs):
|
||||||
return MockFileMetadata('foo', '/foo')
|
return MockFileMetadata('foo', '/foo')
|
||||||
|
|
||||||
|
|
||||||
def download_file(*args, **kwargs):
|
def download_file(*args, **kwargs):
|
||||||
return [MockFileMetadata('foo', '/foo'), MockHttpResponse('boo')]
|
return [MockFileMetadata('foo', '/foo'), MockHttpResponse('boo')]
|
||||||
|
|
||||||
|
|
||||||
def search_files(*args, **kwargs):
|
def search_files(*args, **kwargs):
|
||||||
return MockSearchResult([
|
return MockSearchResult(
|
||||||
MockSearchMatch(
|
[
|
||||||
MockFileMetadata('foo', '/foo')
|
MockSearchMatch(MockFileMetadata('foo', '/foo')),
|
||||||
),
|
MockSearchMatch(MockFileMetadata('fooboo', '/fooboo')),
|
||||||
MockSearchMatch(
|
]
|
||||||
MockFileMetadata('fooboo', '/fooboo')
|
)
|
||||||
)
|
|
||||||
])
|
|
||||||
|
|
||||||
def get_empty_search_result(*args, **kwargs):
|
def get_empty_search_result(*args, **kwargs):
|
||||||
return MockSearchResult([])
|
return MockSearchResult([])
|
||||||
|
|
||||||
|
|
||||||
def get_shared_link(*args, **kwargs):
|
def get_shared_link(*args, **kwargs):
|
||||||
return MockPathLinkMetadata('http://www.foo.com/boo')
|
return MockPathLinkMetadata('http://www.foo.com/boo')
|
||||||
|
|
||||||
|
|
||||||
def get_help() -> str:
|
def get_help() -> str:
|
||||||
return '''
|
return '''
|
||||||
Example commands:
|
Example commands:
|
||||||
|
@ -73,6 +72,7 @@ def get_help() -> str:
|
||||||
```
|
```
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
class TestDropboxBot(BotTestCase, DefaultTests):
|
class TestDropboxBot(BotTestCase, DefaultTests):
|
||||||
bot_name = "dropbox_share"
|
bot_name = "dropbox_share"
|
||||||
config_info = {"access_token": "1234567890"}
|
config_info = {"access_token": "1234567890"}
|
||||||
|
@ -83,116 +83,151 @@ class TestDropboxBot(BotTestCase, DefaultTests):
|
||||||
self.verify_reply('help', get_help())
|
self.verify_reply('help', get_help())
|
||||||
|
|
||||||
def test_dbx_ls_root(self):
|
def test_dbx_ls_root(self):
|
||||||
bot_response = " - [foo](https://www.dropbox.com/home/foo)\n"\
|
bot_response = (
|
||||||
" - [boo](https://www.dropbox.com/home/boo)"
|
" - [foo](https://www.dropbox.com/home/foo)\n"
|
||||||
with patch('dropbox.Dropbox.files_list_folder', side_effect=get_root_files_list), \
|
" - [boo](https://www.dropbox.com/home/boo)"
|
||||||
self.mock_config_info(self.config_info):
|
)
|
||||||
|
with patch(
|
||||||
|
'dropbox.Dropbox.files_list_folder', side_effect=get_root_files_list
|
||||||
|
), self.mock_config_info(self.config_info):
|
||||||
self.verify_reply("ls", bot_response)
|
self.verify_reply("ls", bot_response)
|
||||||
|
|
||||||
def test_dbx_ls_folder(self):
|
def test_dbx_ls_folder(self):
|
||||||
bot_response = " - [moo](https://www.dropbox.com/home/foo/moo)\n"\
|
bot_response = (
|
||||||
" - [noo](https://www.dropbox.com/home/foo/noo)"
|
" - [moo](https://www.dropbox.com/home/foo/moo)\n"
|
||||||
with patch('dropbox.Dropbox.files_list_folder', side_effect=get_folder_files_list), \
|
" - [noo](https://www.dropbox.com/home/foo/noo)"
|
||||||
self.mock_config_info(self.config_info):
|
)
|
||||||
|
with patch(
|
||||||
|
'dropbox.Dropbox.files_list_folder', side_effect=get_folder_files_list
|
||||||
|
), self.mock_config_info(self.config_info):
|
||||||
self.verify_reply("ls foo", bot_response)
|
self.verify_reply("ls foo", bot_response)
|
||||||
|
|
||||||
def test_dbx_ls_empty(self):
|
def test_dbx_ls_empty(self):
|
||||||
bot_response = '`No files available`'
|
bot_response = '`No files available`'
|
||||||
with patch('dropbox.Dropbox.files_list_folder', side_effect=get_empty_files_list), \
|
with patch(
|
||||||
self.mock_config_info(self.config_info):
|
'dropbox.Dropbox.files_list_folder', side_effect=get_empty_files_list
|
||||||
|
), self.mock_config_info(self.config_info):
|
||||||
self.verify_reply("ls", bot_response)
|
self.verify_reply("ls", bot_response)
|
||||||
|
|
||||||
def test_dbx_ls_error(self):
|
def test_dbx_ls_error(self):
|
||||||
bot_response = "Please provide a correct folder path\n"\
|
bot_response = (
|
||||||
"Usage: `ls <foldername>` to list folders in directory\n"\
|
"Please provide a correct folder path\n"
|
||||||
"or simply `ls` for listing folders in the root directory"
|
"Usage: `ls <foldername>` to list folders in directory\n"
|
||||||
with patch('dropbox.Dropbox.files_list_folder', side_effect=Exception()), \
|
"or simply `ls` for listing folders in the root directory"
|
||||||
self.mock_config_info(self.config_info):
|
)
|
||||||
|
with patch(
|
||||||
|
'dropbox.Dropbox.files_list_folder', side_effect=Exception()
|
||||||
|
), self.mock_config_info(self.config_info):
|
||||||
self.verify_reply("ls", bot_response)
|
self.verify_reply("ls", bot_response)
|
||||||
|
|
||||||
def test_dbx_mkdir(self):
|
def test_dbx_mkdir(self):
|
||||||
bot_response = "CREATED FOLDER: [foo](https://www.dropbox.com/home/foo)"
|
bot_response = "CREATED FOLDER: [foo](https://www.dropbox.com/home/foo)"
|
||||||
with patch('dropbox.Dropbox.files_create_folder', side_effect=create_file), \
|
with patch(
|
||||||
self.mock_config_info(self.config_info):
|
'dropbox.Dropbox.files_create_folder', side_effect=create_file
|
||||||
|
), self.mock_config_info(self.config_info):
|
||||||
self.verify_reply('mkdir foo', bot_response)
|
self.verify_reply('mkdir foo', bot_response)
|
||||||
|
|
||||||
def test_dbx_mkdir_error(self):
|
def test_dbx_mkdir_error(self):
|
||||||
bot_response = "Please provide a correct folder path and name.\n"\
|
bot_response = (
|
||||||
"Usage: `mkdir <foldername>` to create a folder."
|
"Please provide a correct folder path and name.\n"
|
||||||
with patch('dropbox.Dropbox.files_create_folder', side_effect=Exception()), \
|
"Usage: `mkdir <foldername>` to create a folder."
|
||||||
self.mock_config_info(self.config_info):
|
)
|
||||||
|
with patch(
|
||||||
|
'dropbox.Dropbox.files_create_folder', side_effect=Exception()
|
||||||
|
), self.mock_config_info(self.config_info):
|
||||||
self.verify_reply('mkdir foo/bar', bot_response)
|
self.verify_reply('mkdir foo/bar', bot_response)
|
||||||
|
|
||||||
def test_dbx_rm(self):
|
def test_dbx_rm(self):
|
||||||
bot_response = "DELETED File/Folder : [foo](https://www.dropbox.com/home/foo)"
|
bot_response = "DELETED File/Folder : [foo](https://www.dropbox.com/home/foo)"
|
||||||
with patch('dropbox.Dropbox.files_delete', side_effect=create_file), \
|
with patch('dropbox.Dropbox.files_delete', side_effect=create_file), self.mock_config_info(
|
||||||
self.mock_config_info(self.config_info):
|
self.config_info
|
||||||
|
):
|
||||||
self.verify_reply('rm foo', bot_response)
|
self.verify_reply('rm foo', bot_response)
|
||||||
|
|
||||||
def test_dbx_rm_error(self):
|
def test_dbx_rm_error(self):
|
||||||
bot_response = "Please provide a correct folder path and name.\n"\
|
bot_response = (
|
||||||
"Usage: `rm <foldername>` to delete a folder in root directory."
|
"Please provide a correct folder path and name.\n"
|
||||||
with patch('dropbox.Dropbox.files_delete', side_effect=Exception()), \
|
"Usage: `rm <foldername>` to delete a folder in root directory."
|
||||||
self.mock_config_info(self.config_info):
|
)
|
||||||
|
with patch('dropbox.Dropbox.files_delete', side_effect=Exception()), self.mock_config_info(
|
||||||
|
self.config_info
|
||||||
|
):
|
||||||
self.verify_reply('rm foo', bot_response)
|
self.verify_reply('rm foo', bot_response)
|
||||||
|
|
||||||
def test_dbx_write(self):
|
def test_dbx_write(self):
|
||||||
bot_response = "Written to file: [foo](https://www.dropbox.com/home/foo)"
|
bot_response = "Written to file: [foo](https://www.dropbox.com/home/foo)"
|
||||||
with patch('dropbox.Dropbox.files_upload', side_effect=create_file), \
|
with patch('dropbox.Dropbox.files_upload', side_effect=create_file), self.mock_config_info(
|
||||||
self.mock_config_info(self.config_info):
|
self.config_info
|
||||||
|
):
|
||||||
self.verify_reply('write foo boo', bot_response)
|
self.verify_reply('write foo boo', bot_response)
|
||||||
|
|
||||||
def test_dbx_write_error(self):
|
def test_dbx_write_error(self):
|
||||||
bot_response = "Incorrect file path or file already exists.\nUsage: `write <filename> CONTENT`"
|
bot_response = (
|
||||||
with patch('dropbox.Dropbox.files_upload', side_effect=Exception()), \
|
"Incorrect file path or file already exists.\nUsage: `write <filename> CONTENT`"
|
||||||
self.mock_config_info(self.config_info):
|
)
|
||||||
|
with patch('dropbox.Dropbox.files_upload', side_effect=Exception()), self.mock_config_info(
|
||||||
|
self.config_info
|
||||||
|
):
|
||||||
self.verify_reply('write foo boo', bot_response)
|
self.verify_reply('write foo boo', bot_response)
|
||||||
|
|
||||||
def test_dbx_read(self):
|
def test_dbx_read(self):
|
||||||
bot_response = "**foo** :\nboo"
|
bot_response = "**foo** :\nboo"
|
||||||
with patch('dropbox.Dropbox.files_download', side_effect=download_file), \
|
with patch(
|
||||||
self.mock_config_info(self.config_info):
|
'dropbox.Dropbox.files_download', side_effect=download_file
|
||||||
|
), self.mock_config_info(self.config_info):
|
||||||
self.verify_reply('read foo', bot_response)
|
self.verify_reply('read foo', bot_response)
|
||||||
|
|
||||||
def test_dbx_read_error(self):
|
def test_dbx_read_error(self):
|
||||||
bot_response = "Please provide a correct file path\n"\
|
bot_response = (
|
||||||
"Usage: `read <filename>` to read content of a file"
|
"Please provide a correct file path\n"
|
||||||
with patch('dropbox.Dropbox.files_download', side_effect=Exception()), \
|
"Usage: `read <filename>` to read content of a file"
|
||||||
self.mock_config_info(self.config_info):
|
)
|
||||||
|
with patch(
|
||||||
|
'dropbox.Dropbox.files_download', side_effect=Exception()
|
||||||
|
), self.mock_config_info(self.config_info):
|
||||||
self.verify_reply('read foo', bot_response)
|
self.verify_reply('read foo', bot_response)
|
||||||
|
|
||||||
def test_dbx_search(self):
|
def test_dbx_search(self):
|
||||||
bot_response = " - [foo](https://www.dropbox.com/home/foo)\n - [fooboo](https://www.dropbox.com/home/fooboo)"
|
bot_response = " - [foo](https://www.dropbox.com/home/foo)\n - [fooboo](https://www.dropbox.com/home/fooboo)"
|
||||||
with patch('dropbox.Dropbox.files_search', side_effect=search_files), \
|
with patch('dropbox.Dropbox.files_search', side_effect=search_files), self.mock_config_info(
|
||||||
self.mock_config_info(self.config_info):
|
self.config_info
|
||||||
|
):
|
||||||
self.verify_reply('search foo', bot_response)
|
self.verify_reply('search foo', bot_response)
|
||||||
|
|
||||||
def test_dbx_search_empty(self):
|
def test_dbx_search_empty(self):
|
||||||
bot_response = "No files/folders found matching your query.\n"\
|
bot_response = (
|
||||||
"For file name searching, the last token is used for prefix matching"\
|
"No files/folders found matching your query.\n"
|
||||||
" (i.e. “bat c” matches “bat cave” but not “batman car”)."
|
"For file name searching, the last token is used for prefix matching"
|
||||||
with patch('dropbox.Dropbox.files_search', side_effect=get_empty_search_result), \
|
" (i.e. “bat c” matches “bat cave” but not “batman car”)."
|
||||||
self.mock_config_info(self.config_info):
|
)
|
||||||
|
with patch(
|
||||||
|
'dropbox.Dropbox.files_search', side_effect=get_empty_search_result
|
||||||
|
), self.mock_config_info(self.config_info):
|
||||||
self.verify_reply('search boo --fd foo', bot_response)
|
self.verify_reply('search boo --fd foo', bot_response)
|
||||||
|
|
||||||
def test_dbx_search_error(self):
|
def test_dbx_search_error(self):
|
||||||
bot_response = "Usage: `search <foldername> query --mr 10 --fd <folderName>`\n"\
|
bot_response = (
|
||||||
"Note:`--mr <int>` is optional and is used to specify maximun results.\n"\
|
"Usage: `search <foldername> query --mr 10 --fd <folderName>`\n"
|
||||||
" `--fd <folderName>` to search in specific folder."
|
"Note:`--mr <int>` is optional and is used to specify maximun results.\n"
|
||||||
with patch('dropbox.Dropbox.files_search', side_effect=Exception()), \
|
" `--fd <folderName>` to search in specific folder."
|
||||||
self.mock_config_info(self.config_info):
|
)
|
||||||
|
with patch('dropbox.Dropbox.files_search', side_effect=Exception()), self.mock_config_info(
|
||||||
|
self.config_info
|
||||||
|
):
|
||||||
self.verify_reply('search foo', bot_response)
|
self.verify_reply('search foo', bot_response)
|
||||||
|
|
||||||
def test_dbx_share(self):
|
def test_dbx_share(self):
|
||||||
bot_response = 'http://www.foo.com/boo'
|
bot_response = 'http://www.foo.com/boo'
|
||||||
with patch('dropbox.Dropbox.sharing_create_shared_link', side_effect=get_shared_link), \
|
with patch(
|
||||||
self.mock_config_info(self.config_info):
|
'dropbox.Dropbox.sharing_create_shared_link', side_effect=get_shared_link
|
||||||
|
), self.mock_config_info(self.config_info):
|
||||||
self.verify_reply('share boo', bot_response)
|
self.verify_reply('share boo', bot_response)
|
||||||
|
|
||||||
def test_dbx_share_error(self):
|
def test_dbx_share_error(self):
|
||||||
bot_response = "Please provide a correct file name.\nUsage: `share <filename>`"
|
bot_response = "Please provide a correct file name.\nUsage: `share <filename>`"
|
||||||
with patch('dropbox.Dropbox.sharing_create_shared_link', side_effect=Exception()), \
|
with patch(
|
||||||
self.mock_config_info(self.config_info):
|
'dropbox.Dropbox.sharing_create_shared_link', side_effect=Exception()
|
||||||
|
), self.mock_config_info(self.config_info):
|
||||||
self.verify_reply('share boo', bot_response)
|
self.verify_reply('share boo', bot_response)
|
||||||
|
|
||||||
def test_dbx_help(self):
|
def test_dbx_help(self):
|
||||||
|
|
|
@ -6,23 +6,28 @@ class MockFileMetadata:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.path_lower = path_lower
|
self.path_lower = path_lower
|
||||||
|
|
||||||
|
|
||||||
class MockListFolderResult:
|
class MockListFolderResult:
|
||||||
def __init__(self, entries: str, has_more: str):
|
def __init__(self, entries: str, has_more: str):
|
||||||
self.entries = entries
|
self.entries = entries
|
||||||
self.has_more = has_more
|
self.has_more = has_more
|
||||||
|
|
||||||
|
|
||||||
class MockSearchMatch:
|
class MockSearchMatch:
|
||||||
def __init__(self, metadata: List[MockFileMetadata]):
|
def __init__(self, metadata: List[MockFileMetadata]):
|
||||||
self.metadata = metadata
|
self.metadata = metadata
|
||||||
|
|
||||||
|
|
||||||
class MockSearchResult:
|
class MockSearchResult:
|
||||||
def __init__(self, matches: List[MockSearchMatch]):
|
def __init__(self, matches: List[MockSearchMatch]):
|
||||||
self.matches = matches
|
self.matches = matches
|
||||||
|
|
||||||
|
|
||||||
class MockPathLinkMetadata:
|
class MockPathLinkMetadata:
|
||||||
def __init__(self, url: str):
|
def __init__(self, url: str):
|
||||||
self.url = url
|
self.url = url
|
||||||
|
|
||||||
|
|
||||||
class MockHttpResponse:
|
class MockHttpResponse:
|
||||||
def __init__(self, text: str):
|
def __init__(self, text: str):
|
||||||
self.text = text
|
self.text = text
|
||||||
|
|
|
@ -20,6 +20,7 @@ def encrypt(text: str) -> str:
|
||||||
|
|
||||||
return newtext
|
return newtext
|
||||||
|
|
||||||
|
|
||||||
class EncryptHandler:
|
class EncryptHandler:
|
||||||
'''
|
'''
|
||||||
This bot allows users to quickly encrypt messages using ROT13 encryption.
|
This bot allows users to quickly encrypt messages using ROT13 encryption.
|
||||||
|
@ -43,4 +44,5 @@ class EncryptHandler:
|
||||||
send_content = "Encrypted/Decrypted text: " + temp_content
|
send_content = "Encrypted/Decrypted text: " + temp_content
|
||||||
return send_content
|
return send_content
|
||||||
|
|
||||||
|
|
||||||
handler_class = EncryptHandler
|
handler_class = EncryptHandler
|
||||||
|
|
|
@ -40,4 +40,5 @@ class FileUploaderHandler:
|
||||||
uploaded_file_reply = '[{}]({})'.format(path.name, upload['uri'])
|
uploaded_file_reply = '[{}]({})'.format(path.name, upload['uri'])
|
||||||
bot_handler.send_reply(message, uploaded_file_reply)
|
bot_handler.send_reply(message, uploaded_file_reply)
|
||||||
|
|
||||||
|
|
||||||
handler_class = FileUploaderHandler
|
handler_class = FileUploaderHandler
|
||||||
|
|
|
@ -15,20 +15,28 @@ class TestFileUploaderBot(BotTestCase, DefaultTests):
|
||||||
@patch('pathlib.Path.is_file', return_value=True)
|
@patch('pathlib.Path.is_file', return_value=True)
|
||||||
def test_file_upload_failed(self, is_file: Mock, resolve: Mock) -> None:
|
def test_file_upload_failed(self, is_file: Mock, resolve: Mock) -> None:
|
||||||
server_reply = dict(result='', msg='error')
|
server_reply = dict(result='', msg='error')
|
||||||
with patch('zulip_bots.test_lib.StubBotHandler.upload_file_from_path',
|
with patch(
|
||||||
return_value=server_reply):
|
'zulip_bots.test_lib.StubBotHandler.upload_file_from_path', return_value=server_reply
|
||||||
self.verify_reply('file.txt', 'Failed to upload `{}` file: error'.format(Path('file.txt').resolve()))
|
):
|
||||||
|
self.verify_reply(
|
||||||
|
'file.txt', 'Failed to upload `{}` file: error'.format(Path('file.txt').resolve())
|
||||||
|
)
|
||||||
|
|
||||||
@patch('pathlib.Path.resolve', return_value=Path('/file.txt'))
|
@patch('pathlib.Path.resolve', return_value=Path('/file.txt'))
|
||||||
@patch('pathlib.Path.is_file', return_value=True)
|
@patch('pathlib.Path.is_file', return_value=True)
|
||||||
def test_file_upload_success(self, is_file: Mock, resolve: Mock) -> None:
|
def test_file_upload_success(self, is_file: Mock, resolve: Mock) -> None:
|
||||||
server_reply = dict(result='success', uri='https://file/uri')
|
server_reply = dict(result='success', uri='https://file/uri')
|
||||||
with patch('zulip_bots.test_lib.StubBotHandler.upload_file_from_path',
|
with patch(
|
||||||
return_value=server_reply):
|
'zulip_bots.test_lib.StubBotHandler.upload_file_from_path', return_value=server_reply
|
||||||
|
):
|
||||||
self.verify_reply('file.txt', '[file.txt](https://file/uri)')
|
self.verify_reply('file.txt', '[file.txt](https://file/uri)')
|
||||||
|
|
||||||
def test_help(self):
|
def test_help(self):
|
||||||
self.verify_reply('help',
|
self.verify_reply(
|
||||||
('Use this bot with any of the following commands:'
|
'help',
|
||||||
'\n* `@uploader <local_file_path>` : Upload a file, where `<local_file_path>` is the path to the file'
|
(
|
||||||
'\n* `@uploader help` : Display help message'))
|
'Use this bot with any of the following commands:'
|
||||||
|
'\n* `@uploader <local_file_path>` : Upload a file, where `<local_file_path>` is the path to the file'
|
||||||
|
'\n* `@uploader help` : Display help message'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
@ -21,6 +21,7 @@ def find_recipient_id(users: List[Any], recipient_name: str) -> str:
|
||||||
if recipient_name == user['firstName']:
|
if recipient_name == user['firstName']:
|
||||||
return user['id']
|
return user['id']
|
||||||
|
|
||||||
|
|
||||||
# Make request to given flock URL and return a two-element tuple
|
# Make request to given flock URL and return a two-element tuple
|
||||||
# whose left-hand value contains JSON body of response (or None if request failed)
|
# whose left-hand value contains JSON body of response (or None if request failed)
|
||||||
# and whose right-hand value contains an error message (or None if request succeeded)
|
# and whose right-hand value contains an error message (or None if request succeeded)
|
||||||
|
@ -34,14 +35,15 @@ def make_flock_request(url: str, params: Dict[str, str]) -> Tuple[Any, str]:
|
||||||
right now.\nPlease try again later"
|
right now.\nPlease try again later"
|
||||||
return (None, error)
|
return (None, error)
|
||||||
|
|
||||||
|
|
||||||
# Returns two-element tuple whose left-hand value contains recipient
|
# Returns two-element tuple whose left-hand value contains recipient
|
||||||
# user's ID (or None if it was not found) and right-hand value contains
|
# user's ID (or None if it was not found) and right-hand value contains
|
||||||
# an error message (or None if recipient user's ID was found)
|
# an error message (or None if recipient user's ID was found)
|
||||||
def get_recipient_id(recipient_name: str, config: Dict[str, str]) -> Tuple[Optional[str], Optional[str]]:
|
def get_recipient_id(
|
||||||
|
recipient_name: str, config: Dict[str, str]
|
||||||
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
token = config['token']
|
token = config['token']
|
||||||
payload = {
|
payload = {'token': token}
|
||||||
'token': token
|
|
||||||
}
|
|
||||||
users, error = make_flock_request(USERS_LIST_URL, payload)
|
users, error = make_flock_request(USERS_LIST_URL, payload)
|
||||||
if users is None:
|
if users is None:
|
||||||
return (None, error)
|
return (None, error)
|
||||||
|
@ -53,6 +55,7 @@ def get_recipient_id(recipient_name: str, config: Dict[str, str]) -> Tuple[Optio
|
||||||
else:
|
else:
|
||||||
return (recipient_id, None)
|
return (recipient_id, None)
|
||||||
|
|
||||||
|
|
||||||
# This handles the message sending work.
|
# This handles the message sending work.
|
||||||
def get_flock_response(content: str, config: Dict[str, str]) -> str:
|
def get_flock_response(content: str, config: Dict[str, str]) -> str:
|
||||||
token = config['token']
|
token = config['token']
|
||||||
|
@ -67,11 +70,7 @@ def get_flock_response(content: str, config: Dict[str, str]) -> str:
|
||||||
if len(str(recipient_id)) > 30:
|
if len(str(recipient_id)) > 30:
|
||||||
return "Found user is invalid."
|
return "Found user is invalid."
|
||||||
|
|
||||||
payload = {
|
payload = {'to': recipient_id, 'text': message, 'token': token}
|
||||||
'to': recipient_id,
|
|
||||||
'text': message,
|
|
||||||
'token': token
|
|
||||||
}
|
|
||||||
res, error = make_flock_request(SEND_MESSAGE_URL, payload)
|
res, error = make_flock_request(SEND_MESSAGE_URL, payload)
|
||||||
if res is None:
|
if res is None:
|
||||||
return error
|
return error
|
||||||
|
@ -81,6 +80,7 @@ def get_flock_response(content: str, config: Dict[str, str]) -> str:
|
||||||
else:
|
else:
|
||||||
return "Message sending failed :slightly_frowning_face:. Please try again."
|
return "Message sending failed :slightly_frowning_face:. Please try again."
|
||||||
|
|
||||||
|
|
||||||
def get_flock_bot_response(content: str, config: Dict[str, str]) -> None:
|
def get_flock_bot_response(content: str, config: Dict[str, str]) -> None:
|
||||||
content = content.strip()
|
content = content.strip()
|
||||||
if content == '' or content == 'help':
|
if content == '' or content == 'help':
|
||||||
|
@ -89,6 +89,7 @@ def get_flock_bot_response(content: str, config: Dict[str, str]) -> None:
|
||||||
result = get_flock_response(content, config)
|
result = get_flock_response(content, config)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class FlockHandler:
|
class FlockHandler:
|
||||||
'''
|
'''
|
||||||
This is flock bot. Now you can send messages to any of your
|
This is flock bot. Now you can send messages to any of your
|
||||||
|
@ -106,4 +107,5 @@ right from Zulip.'''
|
||||||
response = get_flock_bot_response(message['content'], self.config_info)
|
response = get_flock_bot_response(message['content'], self.config_info)
|
||||||
bot_handler.send_reply(message, response)
|
bot_handler.send_reply(message, response)
|
||||||
|
|
||||||
|
|
||||||
handler_class = FlockHandler
|
handler_class = FlockHandler
|
||||||
|
|
|
@ -9,11 +9,7 @@ class TestFlockBot(BotTestCase, DefaultTests):
|
||||||
bot_name = "flock"
|
bot_name = "flock"
|
||||||
normal_config = {"token": "12345"}
|
normal_config = {"token": "12345"}
|
||||||
|
|
||||||
message_config = {
|
message_config = {"token": "12345", "text": "Ricky: test message", "to": "u:somekey"}
|
||||||
"token": "12345",
|
|
||||||
"text": "Ricky: test message",
|
|
||||||
"to": "u:somekey"
|
|
||||||
}
|
|
||||||
|
|
||||||
help_message = '''
|
help_message = '''
|
||||||
You can send messages to any Flock user associated with your account from Zulip.
|
You can send messages to any Flock user associated with your account from Zulip.
|
||||||
|
@ -27,52 +23,62 @@ You can send messages to any Flock user associated with your account from Zulip.
|
||||||
self.verify_reply('', self.help_message)
|
self.verify_reply('', self.help_message)
|
||||||
|
|
||||||
def test_fetch_id_connection_error(self) -> None:
|
def test_fetch_id_connection_error(self) -> None:
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), patch(
|
||||||
patch('requests.get', side_effect=ConnectionError()), \
|
'requests.get', side_effect=ConnectionError()
|
||||||
patch('logging.exception'):
|
), patch('logging.exception'):
|
||||||
self.verify_reply('tyler: Hey tyler', "Uh-Oh, couldn\'t process the request \
|
self.verify_reply(
|
||||||
right now.\nPlease try again later")
|
'tyler: Hey tyler',
|
||||||
|
"Uh-Oh, couldn\'t process the request \
|
||||||
|
right now.\nPlease try again later",
|
||||||
|
)
|
||||||
|
|
||||||
def test_response_connection_error(self) -> None:
|
def test_response_connection_error(self) -> None:
|
||||||
with self.mock_config_info(self.message_config), \
|
with self.mock_config_info(self.message_config), patch(
|
||||||
patch('requests.get', side_effect=ConnectionError()), \
|
'requests.get', side_effect=ConnectionError()
|
||||||
patch('logging.exception'):
|
), patch('logging.exception'):
|
||||||
self.verify_reply('Ricky: test message', "Uh-Oh, couldn\'t process the request \
|
self.verify_reply(
|
||||||
right now.\nPlease try again later")
|
'Ricky: test message',
|
||||||
|
"Uh-Oh, couldn\'t process the request \
|
||||||
|
right now.\nPlease try again later",
|
||||||
|
)
|
||||||
|
|
||||||
def test_no_recipient_found(self) -> None:
|
def test_no_recipient_found(self) -> None:
|
||||||
bot_response = "No user found. Make sure you typed it correctly."
|
bot_response = "No user found. Make sure you typed it correctly."
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
|
||||||
self.mock_http_conversation('test_no_recipient_found'):
|
'test_no_recipient_found'
|
||||||
|
):
|
||||||
self.verify_reply('david: hello', bot_response)
|
self.verify_reply('david: hello', bot_response)
|
||||||
|
|
||||||
def test_found_invalid_recipient(self) -> None:
|
def test_found_invalid_recipient(self) -> None:
|
||||||
bot_response = "Found user is invalid."
|
bot_response = "Found user is invalid."
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
|
||||||
self.mock_http_conversation('test_found_invalid_recipient'):
|
'test_found_invalid_recipient'
|
||||||
|
):
|
||||||
self.verify_reply('david: hello', bot_response)
|
self.verify_reply('david: hello', bot_response)
|
||||||
|
|
||||||
@patch('zulip_bots.bots.flock.flock.get_recipient_id')
|
@patch('zulip_bots.bots.flock.flock.get_recipient_id')
|
||||||
def test_message_send_connection_error(self, get_recipient_id: str) -> None:
|
def test_message_send_connection_error(self, get_recipient_id: str) -> None:
|
||||||
bot_response = "Uh-Oh, couldn't process the request right now.\nPlease try again later"
|
bot_response = "Uh-Oh, couldn't process the request right now.\nPlease try again later"
|
||||||
get_recipient_id.return_value = ["u:userid", None]
|
get_recipient_id.return_value = ["u:userid", None]
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), patch(
|
||||||
patch('requests.get', side_effect=ConnectionError()), \
|
'requests.get', side_effect=ConnectionError()
|
||||||
patch('logging.exception'):
|
), patch('logging.exception'):
|
||||||
self.verify_reply('Rishabh: hi there', bot_response)
|
self.verify_reply('Rishabh: hi there', bot_response)
|
||||||
|
|
||||||
@patch('zulip_bots.bots.flock.flock.get_recipient_id')
|
@patch('zulip_bots.bots.flock.flock.get_recipient_id')
|
||||||
def test_message_send_success(self, get_recipient_id: str) -> None:
|
def test_message_send_success(self, get_recipient_id: str) -> None:
|
||||||
bot_response = "Message sent."
|
bot_response = "Message sent."
|
||||||
get_recipient_id.return_value = ["u:userid", None]
|
get_recipient_id.return_value = ["u:userid", None]
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
|
||||||
self.mock_http_conversation('test_message_send_success'):
|
'test_message_send_success'
|
||||||
|
):
|
||||||
self.verify_reply('Rishabh: hi there', bot_response)
|
self.verify_reply('Rishabh: hi there', bot_response)
|
||||||
|
|
||||||
@patch('zulip_bots.bots.flock.flock.get_recipient_id')
|
@patch('zulip_bots.bots.flock.flock.get_recipient_id')
|
||||||
def test_message_send_failed(self, get_recipient_id: str) -> None:
|
def test_message_send_failed(self, get_recipient_id: str) -> None:
|
||||||
bot_response = "Message sending failed :slightly_frowning_face:. Please try again."
|
bot_response = "Message sending failed :slightly_frowning_face:. Please try again."
|
||||||
get_recipient_id.return_value = ["u:invalid", None]
|
get_recipient_id.return_value = ["u:invalid", None]
|
||||||
with self.mock_config_info(self.normal_config), \
|
with self.mock_config_info(self.normal_config), self.mock_http_conversation(
|
||||||
self.mock_http_conversation('test_message_send_failed'):
|
'test_message_send_failed'
|
||||||
|
):
|
||||||
self.verify_reply('Rishabh: hi there', bot_response)
|
self.verify_reply('Rishabh: hi there', bot_response)
|
||||||
|
|
|
@ -32,18 +32,22 @@ class FollowupHandler:
|
||||||
|
|
||||||
def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None:
|
def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None:
|
||||||
if message['content'] == '':
|
if message['content'] == '':
|
||||||
bot_response = "Please specify the message you want to send to followup stream after @mention-bot"
|
bot_response = (
|
||||||
|
"Please specify the message you want to send to followup stream after @mention-bot"
|
||||||
|
)
|
||||||
bot_handler.send_reply(message, bot_response)
|
bot_handler.send_reply(message, bot_response)
|
||||||
elif message['content'] == 'help':
|
elif message['content'] == 'help':
|
||||||
bot_handler.send_reply(message, self.usage())
|
bot_handler.send_reply(message, self.usage())
|
||||||
else:
|
else:
|
||||||
bot_response = self.get_bot_followup_response(message)
|
bot_response = self.get_bot_followup_response(message)
|
||||||
bot_handler.send_message(dict(
|
bot_handler.send_message(
|
||||||
type='stream',
|
dict(
|
||||||
to=self.stream,
|
type='stream',
|
||||||
subject=message['sender_email'],
|
to=self.stream,
|
||||||
content=bot_response,
|
subject=message['sender_email'],
|
||||||
))
|
content=bot_response,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def get_bot_followup_response(self, message: Dict[str, str]) -> str:
|
def get_bot_followup_response(self, message: Dict[str, str]) -> str:
|
||||||
original_content = message['content']
|
original_content = message['content']
|
||||||
|
@ -53,4 +57,5 @@ class FollowupHandler:
|
||||||
|
|
||||||
return new_content
|
return new_content
|
||||||
|
|
||||||
|
|
||||||
handler_class = FollowupHandler
|
handler_class = FollowupHandler
|
||||||
|
|
|
@ -31,7 +31,9 @@ class TestFollowUpBot(BotTestCase, DefaultTests):
|
||||||
self.assertEqual(response['to'], 'issue')
|
self.assertEqual(response['to'], 'issue')
|
||||||
|
|
||||||
def test_bot_responds_to_empty_message(self) -> None:
|
def test_bot_responds_to_empty_message(self) -> None:
|
||||||
bot_response = 'Please specify the message you want to send to followup stream after @mention-bot'
|
bot_response = (
|
||||||
|
'Please specify the message you want to send to followup stream after @mention-bot'
|
||||||
|
)
|
||||||
|
|
||||||
with self.mock_config_info({'stream': 'followup'}):
|
with self.mock_config_info({'stream': 'followup'}):
|
||||||
self.verify_reply('', bot_response)
|
self.verify_reply('', bot_response)
|
||||||
|
|
|
@ -13,7 +13,7 @@ class FrontHandler:
|
||||||
('delete', "Delete a conversation."),
|
('delete', "Delete a conversation."),
|
||||||
('spam', "Mark a conversation as spam."),
|
('spam', "Mark a conversation as spam."),
|
||||||
('open', "Restore a conversation."),
|
('open', "Restore a conversation."),
|
||||||
('comment <text>', "Leave a comment.")
|
('comment <text>', "Leave a comment."),
|
||||||
]
|
]
|
||||||
CNV_ID_REGEXP = 'cnv_(?P<id>[0-9a-z]+)'
|
CNV_ID_REGEXP = 'cnv_(?P<id>[0-9a-z]+)'
|
||||||
COMMENT_PREFIX = "comment "
|
COMMENT_PREFIX = "comment "
|
||||||
|
@ -40,9 +40,11 @@ class FrontHandler:
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def archive(self, bot_handler: BotHandler) -> str:
|
def archive(self, bot_handler: BotHandler) -> str:
|
||||||
response = requests.patch(self.FRONT_API.format(self.conversation_id),
|
response = requests.patch(
|
||||||
headers={"Authorization": self.auth},
|
self.FRONT_API.format(self.conversation_id),
|
||||||
json={"status": "archived"})
|
headers={"Authorization": self.auth},
|
||||||
|
json={"status": "archived"},
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code not in (200, 204):
|
if response.status_code not in (200, 204):
|
||||||
return "Something went wrong."
|
return "Something went wrong."
|
||||||
|
@ -50,9 +52,11 @@ class FrontHandler:
|
||||||
return "Conversation was archived."
|
return "Conversation was archived."
|
||||||
|
|
||||||
def delete(self, bot_handler: BotHandler) -> str:
|
def delete(self, bot_handler: BotHandler) -> str:
|
||||||
response = requests.patch(self.FRONT_API.format(self.conversation_id),
|
response = requests.patch(
|
||||||
headers={"Authorization": self.auth},
|
self.FRONT_API.format(self.conversation_id),
|
||||||
json={"status": "deleted"})
|
headers={"Authorization": self.auth},
|
||||||
|
json={"status": "deleted"},
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code not in (200, 204):
|
if response.status_code not in (200, 204):
|
||||||
return "Something went wrong."
|
return "Something went wrong."
|
||||||
|
@ -60,9 +64,11 @@ class FrontHandler:
|
||||||
return "Conversation was deleted."
|
return "Conversation was deleted."
|
||||||
|
|
||||||
def spam(self, bot_handler: BotHandler) -> str:
|
def spam(self, bot_handler: BotHandler) -> str:
|
||||||
response = requests.patch(self.FRONT_API.format(self.conversation_id),
|
response = requests.patch(
|
||||||
headers={"Authorization": self.auth},
|
self.FRONT_API.format(self.conversation_id),
|
||||||
json={"status": "spam"})
|
headers={"Authorization": self.auth},
|
||||||
|
json={"status": "spam"},
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code not in (200, 204):
|
if response.status_code not in (200, 204):
|
||||||
return "Something went wrong."
|
return "Something went wrong."
|
||||||
|
@ -70,9 +76,11 @@ class FrontHandler:
|
||||||
return "Conversation was marked as spam."
|
return "Conversation was marked as spam."
|
||||||
|
|
||||||
def restore(self, bot_handler: BotHandler) -> str:
|
def restore(self, bot_handler: BotHandler) -> str:
|
||||||
response = requests.patch(self.FRONT_API.format(self.conversation_id),
|
response = requests.patch(
|
||||||
headers={"Authorization": self.auth},
|
self.FRONT_API.format(self.conversation_id),
|
||||||
json={"status": "open"})
|
headers={"Authorization": self.auth},
|
||||||
|
json={"status": "open"},
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code not in (200, 204):
|
if response.status_code not in (200, 204):
|
||||||
return "Something went wrong."
|
return "Something went wrong."
|
||||||
|
@ -80,8 +88,11 @@ class FrontHandler:
|
||||||
return "Conversation was restored."
|
return "Conversation was restored."
|
||||||
|
|
||||||
def comment(self, bot_handler: BotHandler, **kwargs: Any) -> str:
|
def comment(self, bot_handler: BotHandler, **kwargs: Any) -> str:
|
||||||
response = requests.post(self.FRONT_API.format(self.conversation_id) + "/comments",
|
response = requests.post(
|
||||||
headers={"Authorization": self.auth}, json=kwargs)
|
self.FRONT_API.format(self.conversation_id) + "/comments",
|
||||||
|
headers={"Authorization": self.auth},
|
||||||
|
json=kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code not in (200, 201):
|
if response.status_code not in (200, 201):
|
||||||
return "Something went wrong."
|
return "Something went wrong."
|
||||||
|
@ -93,9 +104,12 @@ class FrontHandler:
|
||||||
|
|
||||||
result = re.search(self.CNV_ID_REGEXP, message['subject'])
|
result = re.search(self.CNV_ID_REGEXP, message['subject'])
|
||||||
if not result:
|
if not result:
|
||||||
bot_handler.send_reply(message, "No coversation ID found. Please make "
|
bot_handler.send_reply(
|
||||||
"sure that the name of the topic "
|
message,
|
||||||
"contains a valid coversation ID.")
|
"No coversation ID found. Please make "
|
||||||
|
"sure that the name of the topic "
|
||||||
|
"contains a valid coversation ID.",
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self.conversation_id = result.group()
|
self.conversation_id = result.group()
|
||||||
|
@ -118,10 +132,11 @@ class FrontHandler:
|
||||||
elif command.startswith(self.COMMENT_PREFIX):
|
elif command.startswith(self.COMMENT_PREFIX):
|
||||||
kwargs = {
|
kwargs = {
|
||||||
'author_id': "alt:email:" + message['sender_email'],
|
'author_id': "alt:email:" + message['sender_email'],
|
||||||
'body': command[len(self.COMMENT_PREFIX):]
|
'body': command[len(self.COMMENT_PREFIX) :],
|
||||||
}
|
}
|
||||||
bot_handler.send_reply(message, self.comment(bot_handler, **kwargs))
|
bot_handler.send_reply(message, self.comment(bot_handler, **kwargs))
|
||||||
else:
|
else:
|
||||||
bot_handler.send_reply(message, "Unknown command. Use `help` for instructions.")
|
bot_handler.send_reply(message, "Unknown command. Use `help` for instructions.")
|
||||||
|
|
||||||
|
|
||||||
handler_class = FrontHandler
|
handler_class = FrontHandler
|
||||||
|
|
|
@ -24,11 +24,14 @@ class TestFrontBot(BotTestCase, DefaultTests):
|
||||||
|
|
||||||
def test_help(self) -> None:
|
def test_help(self) -> None:
|
||||||
with self.mock_config_info({'api_key': "TEST"}):
|
with self.mock_config_info({'api_key': "TEST"}):
|
||||||
self.verify_reply('help', "`archive` Archive a conversation.\n"
|
self.verify_reply(
|
||||||
"`delete` Delete a conversation.\n"
|
'help',
|
||||||
"`spam` Mark a conversation as spam.\n"
|
"`archive` Archive a conversation.\n"
|
||||||
"`open` Restore a conversation.\n"
|
"`delete` Delete a conversation.\n"
|
||||||
"`comment <text>` Leave a comment.\n")
|
"`spam` Mark a conversation as spam.\n"
|
||||||
|
"`open` Restore a conversation.\n"
|
||||||
|
"`comment <text>` Leave a comment.\n",
|
||||||
|
)
|
||||||
|
|
||||||
def test_archive(self) -> None:
|
def test_archive(self) -> None:
|
||||||
with self.mock_config_info({'api_key': "TEST"}):
|
with self.mock_config_info({'api_key': "TEST"}):
|
||||||
|
@ -94,6 +97,9 @@ class TestFrontBotWrongTopic(BotTestCase, DefaultTests):
|
||||||
|
|
||||||
def test_no_conversation_id(self) -> None:
|
def test_no_conversation_id(self) -> None:
|
||||||
with self.mock_config_info({'api_key': "TEST"}):
|
with self.mock_config_info({'api_key': "TEST"}):
|
||||||
self.verify_reply('archive', "No coversation ID found. Please make "
|
self.verify_reply(
|
||||||
"sure that the name of the topic "
|
'archive',
|
||||||
"contains a valid coversation ID.")
|
"No coversation ID found. Please make "
|
||||||
|
"sure that the name of the topic "
|
||||||
|
"contains a valid coversation ID.",
|
||||||
|
)
|
||||||
|
|
|
@ -26,12 +26,7 @@ class MockModel:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.current_board = 'mock board'
|
self.current_board = 'mock board'
|
||||||
|
|
||||||
def make_move(
|
def make_move(self, move: str, player: int, is_computer: bool = False) -> Any:
|
||||||
self,
|
|
||||||
move: str,
|
|
||||||
player: int,
|
|
||||||
is_computer: bool = False
|
|
||||||
) -> Any:
|
|
||||||
if not is_computer:
|
if not is_computer:
|
||||||
if int(move.replace('move ', '')) < 9:
|
if int(move.replace('move ', '')) < 9:
|
||||||
return 'mock board'
|
return 'mock board'
|
||||||
|
@ -67,7 +62,7 @@ class GameHandlerBotHandler(GameAdapter):
|
||||||
gameMessageHandler,
|
gameMessageHandler,
|
||||||
rules,
|
rules,
|
||||||
max_players=2,
|
max_players=2,
|
||||||
supports_computer=True
|
supports_computer=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
|
||||||
user_name: str = 'foo',
|
user_name: str = 'foo',
|
||||||
type: str = 'private',
|
type: str = 'private',
|
||||||
stream: str = '',
|
stream: str = '',
|
||||||
subject: str = ''
|
subject: str = '',
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
message = dict(
|
message = dict(
|
||||||
sender_email=user,
|
sender_email=user,
|
||||||
|
@ -38,7 +38,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
|
||||||
user_name: str = 'foo',
|
user_name: str = 'foo',
|
||||||
stream: str = '',
|
stream: str = '',
|
||||||
subject: str = '',
|
subject: str = '',
|
||||||
max_messages: int = 20
|
max_messages: int = 20,
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
This function serves a similar purpose
|
This function serves a similar purpose
|
||||||
|
@ -52,15 +52,12 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
|
||||||
_b, bot_handler = self._get_handlers()
|
_b, bot_handler = self._get_handlers()
|
||||||
type = 'private' if stream == '' else 'stream'
|
type = 'private' if stream == '' else 'stream'
|
||||||
message = self.make_request_message(
|
message = self.make_request_message(
|
||||||
request, user_name + '@example.com', user_name, type, stream, subject)
|
request, user_name + '@example.com', user_name, type, stream, subject
|
||||||
|
)
|
||||||
bot_handler.reset_transcript()
|
bot_handler.reset_transcript()
|
||||||
bot.handle_message(message, bot_handler)
|
bot.handle_message(message, bot_handler)
|
||||||
|
|
||||||
responses = [
|
responses = [message for (method, message) in bot_handler.transcript]
|
||||||
message
|
|
||||||
for (method, message)
|
|
||||||
in bot_handler.transcript
|
|
||||||
]
|
|
||||||
first_response = responses[response_number]
|
first_response = responses[response_number]
|
||||||
self.assertEqual(expected_response, first_response['content'])
|
self.assertEqual(expected_response, first_response['content'])
|
||||||
self.assertLessEqual(len(responses), max_messages)
|
self.assertLessEqual(len(responses), max_messages)
|
||||||
|
@ -70,12 +67,19 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
|
||||||
bot, bot_handler = self._get_handlers()
|
bot, bot_handler = self._get_handlers()
|
||||||
message = {
|
message = {
|
||||||
'sender_email': '{}@example.com'.format(name),
|
'sender_email': '{}@example.com'.format(name),
|
||||||
'sender_full_name': '{}'.format(name)
|
'sender_full_name': '{}'.format(name),
|
||||||
}
|
}
|
||||||
bot.add_user_to_cache(message)
|
bot.add_user_to_cache(message)
|
||||||
return bot
|
return bot
|
||||||
|
|
||||||
def setup_game(self, id: str = '', bot: Any = None, players: List[str] = ['foo', 'baz'], subject: str = 'test game', stream: str = 'test') -> Any:
|
def setup_game(
|
||||||
|
self,
|
||||||
|
id: str = '',
|
||||||
|
bot: Any = None,
|
||||||
|
players: List[str] = ['foo', 'baz'],
|
||||||
|
subject: str = 'test game',
|
||||||
|
stream: str = 'test',
|
||||||
|
) -> Any:
|
||||||
if bot is None:
|
if bot is None:
|
||||||
bot, bot_handler = self._get_handlers()
|
bot, bot_handler = self._get_handlers()
|
||||||
for p in players:
|
for p in players:
|
||||||
|
@ -84,8 +88,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
|
||||||
game_id = 'abc123'
|
game_id = 'abc123'
|
||||||
if id != '':
|
if id != '':
|
||||||
game_id = id
|
game_id = id
|
||||||
instance = GameInstance(bot, False, subject,
|
instance = GameInstance(bot, False, subject, game_id, players_emails, stream)
|
||||||
game_id, players_emails, stream)
|
|
||||||
bot.instances.update({game_id: instance})
|
bot.instances.update({game_id: instance})
|
||||||
instance.turn = -1
|
instance.turn = -1
|
||||||
instance.start()
|
instance.start()
|
||||||
|
@ -95,8 +98,9 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
|
||||||
bot = self.add_user_to_cache('foo')
|
bot = self.add_user_to_cache('foo')
|
||||||
bot.email = 'test-bot@example.com'
|
bot.email = 'test-bot@example.com'
|
||||||
self.add_user_to_cache('test-bot', bot)
|
self.add_user_to_cache('test-bot', bot)
|
||||||
instance = GameInstance(bot, False, 'test game', 'abc123', [
|
instance = GameInstance(
|
||||||
'foo@example.com', 'test-bot@example.com'], 'test')
|
bot, False, 'test game', 'abc123', ['foo@example.com', 'test-bot@example.com'], 'test'
|
||||||
|
)
|
||||||
bot.instances.update({'abc123': instance})
|
bot.instances.update({'abc123': instance})
|
||||||
instance.start()
|
instance.start()
|
||||||
return bot
|
return bot
|
||||||
|
@ -132,41 +136,55 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
|
||||||
self.verify_response('foo bar baz', self.help_message(), 0)
|
self.verify_response('foo bar baz', self.help_message(), 0)
|
||||||
|
|
||||||
def test_exception_handling(self) -> None:
|
def test_exception_handling(self) -> None:
|
||||||
with patch('logging.exception'), \
|
with patch('logging.exception'), patch(
|
||||||
patch('zulip_bots.game_handler.GameAdapter.command_quit',
|
'zulip_bots.game_handler.GameAdapter.command_quit', side_effect=Exception
|
||||||
side_effect=Exception):
|
):
|
||||||
self.verify_response('quit', 'Error .', 0)
|
self.verify_response('quit', 'Error .', 0)
|
||||||
|
|
||||||
def test_not_in_game_messages(self) -> None:
|
def test_not_in_game_messages(self) -> None:
|
||||||
self.verify_response(
|
self.verify_response(
|
||||||
'move 3', 'You are not in a game at the moment. Type `help` for help.', 0, max_messages=1)
|
'move 3',
|
||||||
|
'You are not in a game at the moment. Type `help` for help.',
|
||||||
|
0,
|
||||||
|
max_messages=1,
|
||||||
|
)
|
||||||
self.verify_response(
|
self.verify_response(
|
||||||
'quit', 'You are not in a game. Type `help` for all commands.', 0, max_messages=1)
|
'quit', 'You are not in a game. Type `help` for all commands.', 0, max_messages=1
|
||||||
|
)
|
||||||
|
|
||||||
def test_start_game_with_name(self) -> None:
|
def test_start_game_with_name(self) -> None:
|
||||||
bot = self.add_user_to_cache('baz')
|
bot = self.add_user_to_cache('baz')
|
||||||
self.verify_response('start game with @**baz**',
|
self.verify_response(
|
||||||
'You\'ve sent an invitation to play foo test game with @**baz**', 1, bot=bot)
|
'start game with @**baz**',
|
||||||
|
'You\'ve sent an invitation to play foo test game with @**baz**',
|
||||||
|
1,
|
||||||
|
bot=bot,
|
||||||
|
)
|
||||||
self.assertEqual(len(bot.invites), 1)
|
self.assertEqual(len(bot.invites), 1)
|
||||||
|
|
||||||
def test_start_game_with_email(self) -> None:
|
def test_start_game_with_email(self) -> None:
|
||||||
bot = self.add_user_to_cache('baz')
|
bot = self.add_user_to_cache('baz')
|
||||||
self.verify_response('start game with baz@example.com',
|
self.verify_response(
|
||||||
'You\'ve sent an invitation to play foo test game with @**baz**', 1, bot=bot)
|
'start game with baz@example.com',
|
||||||
|
'You\'ve sent an invitation to play foo test game with @**baz**',
|
||||||
|
1,
|
||||||
|
bot=bot,
|
||||||
|
)
|
||||||
self.assertEqual(len(bot.invites), 1)
|
self.assertEqual(len(bot.invites), 1)
|
||||||
|
|
||||||
def test_join_game_and_start_in_stream(self) -> None:
|
def test_join_game_and_start_in_stream(self) -> None:
|
||||||
bot = self.add_user_to_cache('baz')
|
bot = self.add_user_to_cache('baz')
|
||||||
self.add_user_to_cache('foo', bot)
|
self.add_user_to_cache('foo', bot)
|
||||||
bot.invites = {
|
bot.invites = {'abc': {'stream': 'test', 'subject': 'test game', 'host': 'foo@example.com'}}
|
||||||
'abc': {
|
self.verify_response(
|
||||||
'stream': 'test',
|
'join',
|
||||||
'subject': 'test game',
|
'@**baz** has joined the game',
|
||||||
'host': 'foo@example.com'
|
0,
|
||||||
}
|
bot=bot,
|
||||||
}
|
stream='test',
|
||||||
self.verify_response('join', '@**baz** has joined the game', 0, bot=bot,
|
subject='test game',
|
||||||
stream='test', subject='test game', user_name='baz')
|
user_name='baz',
|
||||||
|
)
|
||||||
self.assertEqual(len(bot.instances.keys()), 1)
|
self.assertEqual(len(bot.instances.keys()), 1)
|
||||||
|
|
||||||
def test_start_game_in_stream(self) -> None:
|
def test_start_game_in_stream(self) -> None:
|
||||||
|
@ -175,7 +193,7 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
|
||||||
'**foo** wants to play **foo test game**. Type @**test-bot** join to play them!',
|
'**foo** wants to play **foo test game**. Type @**test-bot** join to play them!',
|
||||||
0,
|
0,
|
||||||
stream='test',
|
stream='test',
|
||||||
subject='test game'
|
subject='test game',
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_start_invite_game_in_stream(self) -> None:
|
def test_start_invite_game_in_stream(self) -> None:
|
||||||
|
@ -186,12 +204,19 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
|
||||||
2,
|
2,
|
||||||
bot=bot,
|
bot=bot,
|
||||||
stream='test',
|
stream='test',
|
||||||
subject='game test'
|
subject='game test',
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_join_no_game(self) -> None:
|
def test_join_no_game(self) -> None:
|
||||||
self.verify_response('join', 'There is not a game in this subject. Type `help` for all commands.',
|
self.verify_response(
|
||||||
0, stream='test', subject='test game', user_name='baz', max_messages=1)
|
'join',
|
||||||
|
'There is not a game in this subject. Type `help` for all commands.',
|
||||||
|
0,
|
||||||
|
stream='test',
|
||||||
|
subject='test game',
|
||||||
|
user_name='baz',
|
||||||
|
max_messages=1,
|
||||||
|
)
|
||||||
|
|
||||||
def test_accept_invitation(self) -> None:
|
def test_accept_invitation(self) -> None:
|
||||||
bot = self.add_user_to_cache('baz')
|
bot = self.add_user_to_cache('baz')
|
||||||
|
@ -201,80 +226,96 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
|
||||||
'subject': '###private###',
|
'subject': '###private###',
|
||||||
'stream': 'games',
|
'stream': 'games',
|
||||||
'host': 'foo@example.com',
|
'host': 'foo@example.com',
|
||||||
'baz@example.com': 'p'
|
'baz@example.com': 'p',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.verify_response(
|
self.verify_response(
|
||||||
'accept', 'Accepted invitation to play **foo test game** from @**foo**.', 0, bot, 'baz')
|
'accept', 'Accepted invitation to play **foo test game** from @**foo**.', 0, bot, 'baz'
|
||||||
|
)
|
||||||
|
|
||||||
def test_decline_invitation(self) -> None:
|
def test_decline_invitation(self) -> None:
|
||||||
bot = self.add_user_to_cache('baz')
|
bot = self.add_user_to_cache('baz')
|
||||||
self.add_user_to_cache('foo', bot)
|
self.add_user_to_cache('foo', bot)
|
||||||
bot.invites = {
|
bot.invites = {
|
||||||
'abc': {
|
'abc': {'subject': '###private###', 'host': 'foo@example.com', 'baz@example.com': 'p'}
|
||||||
'subject': '###private###',
|
|
||||||
'host': 'foo@example.com',
|
|
||||||
'baz@example.com': 'p'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.verify_response(
|
self.verify_response(
|
||||||
'decline', 'Declined invitation to play **foo test game** from @**foo**.', 0, bot, 'baz')
|
'decline', 'Declined invitation to play **foo test game** from @**foo**.', 0, bot, 'baz'
|
||||||
|
)
|
||||||
|
|
||||||
def test_quit_invite(self) -> None:
|
def test_quit_invite(self) -> None:
|
||||||
bot = self.add_user_to_cache('foo')
|
bot = self.add_user_to_cache('foo')
|
||||||
bot.invites = {
|
bot.invites = {'abc': {'subject': '###private###', 'host': 'foo@example.com'}}
|
||||||
'abc': {
|
self.verify_response('quit', 'Game cancelled.\n**foo** quit.', 0, bot, 'foo')
|
||||||
'subject': '###private###',
|
|
||||||
'host': 'foo@example.com'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.verify_response(
|
|
||||||
'quit', 'Game cancelled.\n**foo** quit.', 0, bot, 'foo')
|
|
||||||
|
|
||||||
def test_user_already_in_game_errors(self) -> None:
|
def test_user_already_in_game_errors(self) -> None:
|
||||||
bot = self.setup_game()
|
bot = self.setup_game()
|
||||||
self.verify_response('start game with @**baz**',
|
|
||||||
'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1)
|
|
||||||
self.verify_response(
|
self.verify_response(
|
||||||
'start game', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, stream='test', max_messages=1)
|
'start game with @**baz**',
|
||||||
|
'You are already in a game. Type `quit` to leave.',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
max_messages=1,
|
||||||
|
)
|
||||||
self.verify_response(
|
self.verify_response(
|
||||||
'accept', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1)
|
'start game',
|
||||||
|
'You are already in a game. Type `quit` to leave.',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
stream='test',
|
||||||
|
max_messages=1,
|
||||||
|
)
|
||||||
self.verify_response(
|
self.verify_response(
|
||||||
'decline', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1)
|
'accept', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1
|
||||||
|
)
|
||||||
self.verify_response(
|
self.verify_response(
|
||||||
'join', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1)
|
'decline',
|
||||||
|
'You are already in a game. Type `quit` to leave.',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
max_messages=1,
|
||||||
|
)
|
||||||
|
self.verify_response(
|
||||||
|
'join', 'You are already in a game. Type `quit` to leave.', 0, bot=bot, max_messages=1
|
||||||
|
)
|
||||||
|
|
||||||
def test_register_command(self) -> None:
|
def test_register_command(self) -> None:
|
||||||
bot = self.add_user_to_cache('foo')
|
bot = self.add_user_to_cache('foo')
|
||||||
self.verify_response(
|
self.verify_response('register', 'Hello @**foo**. Thanks for registering!', 0, bot, 'foo')
|
||||||
'register', 'Hello @**foo**. Thanks for registering!', 0, bot, 'foo')
|
|
||||||
self.assertIn('foo@example.com', bot.user_cache.keys())
|
self.assertIn('foo@example.com', bot.user_cache.keys())
|
||||||
|
|
||||||
def test_no_active_invite_errors(self) -> None:
|
def test_no_active_invite_errors(self) -> None:
|
||||||
self.verify_response(
|
self.verify_response('accept', 'No active invites. Type `help` for commands.', 0)
|
||||||
'accept', 'No active invites. Type `help` for commands.', 0)
|
self.verify_response('decline', 'No active invites. Type `help` for commands.', 0)
|
||||||
self.verify_response(
|
|
||||||
'decline', 'No active invites. Type `help` for commands.', 0)
|
|
||||||
|
|
||||||
def test_wrong_number_of_players_message(self) -> None:
|
def test_wrong_number_of_players_message(self) -> None:
|
||||||
bot = self.add_user_to_cache('baz')
|
bot = self.add_user_to_cache('baz')
|
||||||
bot.min_players = 5
|
bot.min_players = 5
|
||||||
self.verify_response('start game with @**baz**',
|
self.verify_response(
|
||||||
'You must have at least 5 players to play.\nGame cancelled.', 0, bot=bot)
|
'start game with @**baz**',
|
||||||
|
'You must have at least 5 players to play.\nGame cancelled.',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
)
|
||||||
bot.min_players = 2
|
bot.min_players = 2
|
||||||
bot.max_players = 1
|
bot.max_players = 1
|
||||||
self.verify_response('start game with @**baz**',
|
self.verify_response(
|
||||||
'The maximum number of players for this game is 1.', 0, bot=bot)
|
'start game with @**baz**',
|
||||||
|
'The maximum number of players for this game is 1.',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
)
|
||||||
bot.max_players = 1
|
bot.max_players = 1
|
||||||
bot.invites = {
|
bot.invites = {'abc': {'stream': 'test', 'subject': 'test game', 'host': 'foo@example.com'}}
|
||||||
'abc': {
|
self.verify_response(
|
||||||
'stream': 'test',
|
'join',
|
||||||
'subject': 'test game',
|
'This game is full.',
|
||||||
'host': 'foo@example.com'
|
0,
|
||||||
}
|
bot=bot,
|
||||||
}
|
stream='test',
|
||||||
self.verify_response('join', 'This game is full.', 0, bot=bot,
|
subject='test game',
|
||||||
stream='test', subject='test game', user_name='baz')
|
user_name='baz',
|
||||||
|
)
|
||||||
|
|
||||||
def test_public_accept(self) -> None:
|
def test_public_accept(self) -> None:
|
||||||
bot = self.add_user_to_cache('baz')
|
bot = self.add_user_to_cache('baz')
|
||||||
|
@ -284,70 +325,125 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
|
||||||
'stream': 'test',
|
'stream': 'test',
|
||||||
'subject': 'test game',
|
'subject': 'test game',
|
||||||
'host': 'baz@example.com',
|
'host': 'baz@example.com',
|
||||||
'foo@example.com': 'p'
|
'foo@example.com': 'p',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.verify_response('accept', '@**foo** has accepted the invitation.',
|
self.verify_response(
|
||||||
0, bot=bot, stream='test', subject='test game')
|
'accept',
|
||||||
|
'@**foo** has accepted the invitation.',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
stream='test',
|
||||||
|
subject='test game',
|
||||||
|
)
|
||||||
|
|
||||||
def test_start_game_with_computer(self) -> None:
|
def test_start_game_with_computer(self) -> None:
|
||||||
self.verify_response('start game with @**test-bot**',
|
self.verify_response(
|
||||||
'Wait... That\'s me!', 4, stream='test', subject='test game')
|
'start game with @**test-bot**',
|
||||||
|
'Wait... That\'s me!',
|
||||||
|
4,
|
||||||
|
stream='test',
|
||||||
|
subject='test game',
|
||||||
|
)
|
||||||
|
|
||||||
def test_sent_by_bot(self) -> None:
|
def test_sent_by_bot(self) -> None:
|
||||||
with self.assertRaises(IndexError):
|
with self.assertRaises(IndexError):
|
||||||
self.verify_response(
|
self.verify_response(
|
||||||
'foo', '', 0, user_name='test-bot', stream='test', subject='test game')
|
'foo', '', 0, user_name='test-bot', stream='test', subject='test game'
|
||||||
|
)
|
||||||
|
|
||||||
def test_forfeit(self) -> None:
|
def test_forfeit(self) -> None:
|
||||||
bot = self.setup_game()
|
bot = self.setup_game()
|
||||||
self.verify_response('forfeit', '**foo** forfeited!',
|
self.verify_response(
|
||||||
0, bot=bot, stream='test', subject='test game')
|
'forfeit', '**foo** forfeited!', 0, bot=bot, stream='test', subject='test game'
|
||||||
|
)
|
||||||
|
|
||||||
def test_draw(self) -> None:
|
def test_draw(self) -> None:
|
||||||
bot = self.setup_game()
|
bot = self.setup_game()
|
||||||
self.verify_response('draw', '**foo** has voted for a draw!\nType `draw` to accept',
|
self.verify_response(
|
||||||
0, bot=bot, stream='test', subject='test game')
|
'draw',
|
||||||
self.verify_response('draw', 'It was a draw!', 0, bot=bot, stream='test',
|
'**foo** has voted for a draw!\nType `draw` to accept',
|
||||||
subject='test game', user_name='baz')
|
0,
|
||||||
|
bot=bot,
|
||||||
|
stream='test',
|
||||||
|
subject='test game',
|
||||||
|
)
|
||||||
|
self.verify_response(
|
||||||
|
'draw',
|
||||||
|
'It was a draw!',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
stream='test',
|
||||||
|
subject='test game',
|
||||||
|
user_name='baz',
|
||||||
|
)
|
||||||
|
|
||||||
def test_normal_turns(self) -> None:
|
def test_normal_turns(self) -> None:
|
||||||
bot = self.setup_game()
|
bot = self.setup_game()
|
||||||
self.verify_response('move 3', '**foo** moved in column 3\n\nfoo\n\nIt\'s **baz**\'s (:red_circle:) turn.',
|
self.verify_response(
|
||||||
0, bot=bot, stream='test', subject='test game')
|
'move 3',
|
||||||
self.verify_response('move 3', '**baz** moved in column 3\n\nfoo\n\nIt\'s **foo**\'s (:blue_circle:) turn.',
|
'**foo** moved in column 3\n\nfoo\n\nIt\'s **baz**\'s (:red_circle:) turn.',
|
||||||
0, bot=bot, stream='test', subject='test game', user_name='baz')
|
0,
|
||||||
|
bot=bot,
|
||||||
|
stream='test',
|
||||||
|
subject='test game',
|
||||||
|
)
|
||||||
|
self.verify_response(
|
||||||
|
'move 3',
|
||||||
|
'**baz** moved in column 3\n\nfoo\n\nIt\'s **foo**\'s (:blue_circle:) turn.',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
stream='test',
|
||||||
|
subject='test game',
|
||||||
|
user_name='baz',
|
||||||
|
)
|
||||||
|
|
||||||
def test_wrong_turn(self) -> None:
|
def test_wrong_turn(self) -> None:
|
||||||
bot = self.setup_game()
|
bot = self.setup_game()
|
||||||
self.verify_response('move 5', 'It\'s **foo**\'s (:blue_circle:) turn.', 0,
|
self.verify_response(
|
||||||
bot=bot, stream='test', subject='test game', user_name='baz')
|
'move 5',
|
||||||
|
'It\'s **foo**\'s (:blue_circle:) turn.',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
stream='test',
|
||||||
|
subject='test game',
|
||||||
|
user_name='baz',
|
||||||
|
)
|
||||||
|
|
||||||
def test_private_message_error(self) -> None:
|
def test_private_message_error(self) -> None:
|
||||||
self.verify_response(
|
self.verify_response(
|
||||||
'start game', 'If you are starting a game in private messages, you must invite players. Type `help` for commands.', 0, max_messages=1)
|
'start game',
|
||||||
|
'If you are starting a game in private messages, you must invite players. Type `help` for commands.',
|
||||||
|
0,
|
||||||
|
max_messages=1,
|
||||||
|
)
|
||||||
bot = self.add_user_to_cache('bar')
|
bot = self.add_user_to_cache('bar')
|
||||||
bot.invites = {
|
bot.invites = {
|
||||||
'abcdefg': {
|
'abcdefg': {'host': 'bar@example.com', 'stream': 'test', 'subject': 'test game'}
|
||||||
'host': 'bar@example.com',
|
|
||||||
'stream': 'test',
|
|
||||||
'subject': 'test game'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.verify_response(
|
self.verify_response(
|
||||||
'join', 'You cannot join games in private messages. Type `help` for all commands.', 0, bot=bot, max_messages=1)
|
'join',
|
||||||
|
'You cannot join games in private messages. Type `help` for all commands.',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
max_messages=1,
|
||||||
|
)
|
||||||
|
|
||||||
def test_game_already_in_subject(self) -> None:
|
def test_game_already_in_subject(self) -> None:
|
||||||
bot = self.add_user_to_cache('foo')
|
bot = self.add_user_to_cache('foo')
|
||||||
bot.invites = {
|
bot.invites = {
|
||||||
'abcdefg': {
|
'abcdefg': {'host': 'foo@example.com', 'stream': 'test', 'subject': 'test game'}
|
||||||
'host': 'foo@example.com',
|
|
||||||
'stream': 'test',
|
|
||||||
'subject': 'test game'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.verify_response('start game', 'There is already a game in this stream.', 0,
|
self.verify_response(
|
||||||
bot=bot, stream='test', subject='test game', user_name='baz', max_messages=1)
|
'start game',
|
||||||
|
'There is already a game in this stream.',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
stream='test',
|
||||||
|
subject='test game',
|
||||||
|
user_name='baz',
|
||||||
|
max_messages=1,
|
||||||
|
)
|
||||||
|
|
||||||
# def test_not_authorized(self) -> None:
|
# def test_not_authorized(self) -> None:
|
||||||
# bot = self.setup_game()
|
# bot = self.setup_game()
|
||||||
|
@ -355,32 +451,46 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
|
||||||
# user_name='bar', stream='test', subject='test game', max_messages=1)
|
# user_name='bar', stream='test', subject='test game', max_messages=1)
|
||||||
|
|
||||||
def test_unknown_user(self) -> None:
|
def test_unknown_user(self) -> None:
|
||||||
self.verify_response('start game with @**bar**',
|
self.verify_response(
|
||||||
'I don\'t know @**bar**. Tell them to say @**test-bot** register', 0)
|
'start game with @**bar**',
|
||||||
self.verify_response('start game with bar@example.com',
|
'I don\'t know @**bar**. Tell them to say @**test-bot** register',
|
||||||
'I don\'t know bar@example.com. Tell them to use @**test-bot** register', 0)
|
0,
|
||||||
|
)
|
||||||
|
self.verify_response(
|
||||||
|
'start game with bar@example.com',
|
||||||
|
'I don\'t know bar@example.com. Tell them to use @**test-bot** register',
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
def test_is_user_not_player(self) -> None:
|
def test_is_user_not_player(self) -> None:
|
||||||
bot = self.add_user_to_cache('foo')
|
bot = self.add_user_to_cache('foo')
|
||||||
self.add_user_to_cache('baz', bot)
|
self.add_user_to_cache('baz', bot)
|
||||||
bot.invites = {
|
bot.invites = {'abcdefg': {'host': 'foo@example.com', 'baz@example.com': 'a'}}
|
||||||
'abcdefg': {
|
|
||||||
'host': 'foo@example.com',
|
|
||||||
'baz@example.com': 'a'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.assertFalse(bot.is_user_not_player('foo@example.com'))
|
self.assertFalse(bot.is_user_not_player('foo@example.com'))
|
||||||
self.assertFalse(bot.is_user_not_player('baz@example.com'))
|
self.assertFalse(bot.is_user_not_player('baz@example.com'))
|
||||||
|
|
||||||
def test_move_help_message(self) -> None:
|
def test_move_help_message(self) -> None:
|
||||||
bot = self.setup_game()
|
bot = self.setup_game()
|
||||||
self.verify_response('move 123', '* To make your move during a game, type\n```move <column-number>```',
|
self.verify_response(
|
||||||
0, bot=bot, stream='test', subject='test game')
|
'move 123',
|
||||||
|
'* To make your move during a game, type\n```move <column-number>```',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
stream='test',
|
||||||
|
subject='test game',
|
||||||
|
)
|
||||||
|
|
||||||
def test_invalid_move_message(self) -> None:
|
def test_invalid_move_message(self) -> None:
|
||||||
bot = self.setup_game()
|
bot = self.setup_game()
|
||||||
self.verify_response('move 9', 'Invalid Move.', 0,
|
self.verify_response(
|
||||||
bot=bot, stream='test', subject='test game', max_messages=2)
|
'move 9',
|
||||||
|
'Invalid Move.',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
stream='test',
|
||||||
|
subject='test game',
|
||||||
|
max_messages=2,
|
||||||
|
)
|
||||||
|
|
||||||
def test_get_game_id_by_email(self) -> None:
|
def test_get_game_id_by_email(self) -> None:
|
||||||
bot = self.setup_game()
|
bot = self.setup_game()
|
||||||
|
@ -389,9 +499,13 @@ class TestGameHandlerBot(BotTestCase, DefaultTests):
|
||||||
def test_game_over_and_leaderboard(self) -> None:
|
def test_game_over_and_leaderboard(self) -> None:
|
||||||
bot = self.setup_game()
|
bot = self.setup_game()
|
||||||
bot.put_user_cache()
|
bot.put_user_cache()
|
||||||
with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='foo@example.com'):
|
with patch(
|
||||||
self.verify_response('move 3', '**foo** won! :tada:',
|
'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
|
||||||
1, bot=bot, stream='test', subject='test game')
|
return_value='foo@example.com',
|
||||||
|
):
|
||||||
|
self.verify_response(
|
||||||
|
'move 3', '**foo** won! :tada:', 1, bot=bot, stream='test', subject='test game'
|
||||||
|
)
|
||||||
leaderboard = '**Most wins**\n\n\
|
leaderboard = '**Most wins**\n\n\
|
||||||
Player | Games Won | Games Drawn | Games Lost | Total Games\n\
|
Player | Games Won | Games Drawn | Games Lost | Total Games\n\
|
||||||
--- | --- | --- | --- | --- \n\
|
--- | --- | --- | --- | --- \n\
|
||||||
|
@ -402,27 +516,54 @@ Player | Games Won | Games Drawn | Games Lost | Total Games\n\
|
||||||
|
|
||||||
def test_current_turn_winner(self) -> None:
|
def test_current_turn_winner(self) -> None:
|
||||||
bot = self.setup_game()
|
bot = self.setup_game()
|
||||||
with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='current turn'):
|
with patch(
|
||||||
self.verify_response('move 3', '**foo** won! :tada:',
|
'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
|
||||||
1, bot=bot, stream='test', subject='test game')
|
return_value='current turn',
|
||||||
|
):
|
||||||
|
self.verify_response(
|
||||||
|
'move 3', '**foo** won! :tada:', 1, bot=bot, stream='test', subject='test game'
|
||||||
|
)
|
||||||
|
|
||||||
def test_computer_turn(self) -> None:
|
def test_computer_turn(self) -> None:
|
||||||
bot = self.setup_computer_game()
|
bot = self.setup_computer_game()
|
||||||
self.verify_response('move 3', '**foo** moved in column 3\n\nfoo\n\nIt\'s **test-bot**\'s (:red_circle:) turn.',
|
self.verify_response(
|
||||||
0, bot=bot, stream='test', subject='test game')
|
'move 3',
|
||||||
with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='test-bot@example.com'):
|
'**foo** moved in column 3\n\nfoo\n\nIt\'s **test-bot**\'s (:red_circle:) turn.',
|
||||||
self.verify_response('move 5', 'I won! Well Played!',
|
0,
|
||||||
2, bot=bot, stream='test', subject='test game')
|
bot=bot,
|
||||||
|
stream='test',
|
||||||
|
subject='test game',
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
|
||||||
|
return_value='test-bot@example.com',
|
||||||
|
):
|
||||||
|
self.verify_response(
|
||||||
|
'move 5', 'I won! Well Played!', 2, bot=bot, stream='test', subject='test game'
|
||||||
|
)
|
||||||
|
|
||||||
def test_computer_endgame_responses(self) -> None:
|
def test_computer_endgame_responses(self) -> None:
|
||||||
bot = self.setup_computer_game()
|
bot = self.setup_computer_game()
|
||||||
with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='foo@example.com'):
|
with patch(
|
||||||
self.verify_response('move 5', 'You won! Nice!',
|
'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
|
||||||
2, bot=bot, stream='test', subject='test game')
|
return_value='foo@example.com',
|
||||||
|
):
|
||||||
|
self.verify_response(
|
||||||
|
'move 5', 'You won! Nice!', 2, bot=bot, stream='test', subject='test game'
|
||||||
|
)
|
||||||
bot = self.setup_computer_game()
|
bot = self.setup_computer_game()
|
||||||
with patch('zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over', return_value='draw'):
|
with patch(
|
||||||
self.verify_response('move 5', 'It was a draw! Well Played!',
|
'zulip_bots.bots.game_handler_bot.game_handler_bot.MockModel.determine_game_over',
|
||||||
2, bot=bot, stream='test', subject='test game')
|
return_value='draw',
|
||||||
|
):
|
||||||
|
self.verify_response(
|
||||||
|
'move 5',
|
||||||
|
'It was a draw! Well Played!',
|
||||||
|
2,
|
||||||
|
bot=bot,
|
||||||
|
stream='test',
|
||||||
|
subject='test game',
|
||||||
|
)
|
||||||
|
|
||||||
def test_add_user_statistics(self) -> None:
|
def test_add_user_statistics(self) -> None:
|
||||||
bot = self.add_user_to_cache('foo')
|
bot = self.add_user_to_cache('foo')
|
||||||
|
@ -448,54 +589,82 @@ Player | Games Won | Games Drawn | Games Lost | Total Games\n\
|
||||||
'host': 'foo@example.com',
|
'host': 'foo@example.com',
|
||||||
'baz@example.com': 'a',
|
'baz@example.com': 'a',
|
||||||
'stream': 'test',
|
'stream': 'test',
|
||||||
'subject': 'test game'
|
'subject': 'test game',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.assertEqual(bot.get_game_info('abcdefg'), {
|
self.assertEqual(
|
||||||
'game_id': 'abcdefg',
|
bot.get_game_info('abcdefg'),
|
||||||
'type': 'invite',
|
{
|
||||||
'stream': 'test',
|
'game_id': 'abcdefg',
|
||||||
'subject': 'test game',
|
'type': 'invite',
|
||||||
'players': ['foo@example.com', 'baz@example.com']
|
'stream': 'test',
|
||||||
})
|
'subject': 'test game',
|
||||||
|
'players': ['foo@example.com', 'baz@example.com'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_parse_message(self) -> None:
|
def test_parse_message(self) -> None:
|
||||||
bot = self.setup_game()
|
bot = self.setup_game()
|
||||||
self.verify_response('move 3', 'Join your game using the link below!\n\n> **Game `abc123`**\n\
|
self.verify_response(
|
||||||
|
'move 3',
|
||||||
|
'Join your game using the link below!\n\n> **Game `abc123`**\n\
|
||||||
> foo test game\n\
|
> foo test game\n\
|
||||||
> 2/2 players\n\
|
> 2/2 players\n\
|
||||||
> **[Join Game](/#narrow/stream/test/topic/test game)**', 0, bot=bot)
|
> **[Join Game](/#narrow/stream/test/topic/test game)**',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
)
|
||||||
bot = self.setup_game()
|
bot = self.setup_game()
|
||||||
self.verify_response('move 3', '''Your current game is not in this subject. \n\
|
self.verify_response(
|
||||||
|
'move 3',
|
||||||
|
'''Your current game is not in this subject. \n\
|
||||||
To move subjects, send your message again, otherwise join the game using the link below.
|
To move subjects, send your message again, otherwise join the game using the link below.
|
||||||
|
|
||||||
> **Game `abc123`**
|
> **Game `abc123`**
|
||||||
> foo test game
|
> foo test game
|
||||||
> 2/2 players
|
> 2/2 players
|
||||||
> **[Join Game](/#narrow/stream/test/topic/test game)**''', 0, bot=bot, stream='test 2', subject='game 2')
|
> **[Join Game](/#narrow/stream/test/topic/test game)**''',
|
||||||
self.verify_response('move 3', 'foo', 0, bot=bot,
|
0,
|
||||||
stream='test 2', subject='game 2')
|
bot=bot,
|
||||||
|
stream='test 2',
|
||||||
|
subject='game 2',
|
||||||
|
)
|
||||||
|
self.verify_response('move 3', 'foo', 0, bot=bot, stream='test 2', subject='game 2')
|
||||||
|
|
||||||
def test_change_game_subject(self) -> None:
|
def test_change_game_subject(self) -> None:
|
||||||
bot = self.setup_game('abc123')
|
bot = self.setup_game('abc123')
|
||||||
self.setup_game('abcdefg', bot, ['bar', 'abc'], 'test game 2', 'test2')
|
self.setup_game('abcdefg', bot, ['bar', 'abc'], 'test game 2', 'test2')
|
||||||
self.verify_response('move 3', '''Your current game is not in this subject. \n\
|
self.verify_response(
|
||||||
|
'move 3',
|
||||||
|
'''Your current game is not in this subject. \n\
|
||||||
To move subjects, send your message again, otherwise join the game using the link below.
|
To move subjects, send your message again, otherwise join the game using the link below.
|
||||||
|
|
||||||
> **Game `abcdefg`**
|
> **Game `abcdefg`**
|
||||||
> foo test game
|
> foo test game
|
||||||
> 2/2 players
|
> 2/2 players
|
||||||
> **[Join Game](/#narrow/stream/test2/topic/test game 2)**''', 0, bot=bot, user_name='bar', stream='test game', subject='test2')
|
> **[Join Game](/#narrow/stream/test2/topic/test game 2)**''',
|
||||||
self.verify_response('move 3', 'There is already a game in this subject.',
|
0,
|
||||||
0, bot=bot, user_name='bar', stream='test game', subject='test')
|
bot=bot,
|
||||||
|
user_name='bar',
|
||||||
|
stream='test game',
|
||||||
|
subject='test2',
|
||||||
|
)
|
||||||
|
self.verify_response(
|
||||||
|
'move 3',
|
||||||
|
'There is already a game in this subject.',
|
||||||
|
0,
|
||||||
|
bot=bot,
|
||||||
|
user_name='bar',
|
||||||
|
stream='test game',
|
||||||
|
subject='test',
|
||||||
|
)
|
||||||
bot.invites = {
|
bot.invites = {
|
||||||
'foo bar baz': {
|
'foo bar baz': {
|
||||||
'host': 'foo@example.com',
|
'host': 'foo@example.com',
|
||||||
'baz@example.com': 'a',
|
'baz@example.com': 'a',
|
||||||
'stream': 'test',
|
'stream': 'test',
|
||||||
'subject': 'test game'
|
'subject': 'test game',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bot.change_game_subject('foo bar baz', 'test2',
|
bot.change_game_subject('foo bar baz', 'test2', 'game2', self.make_request_message('foo'))
|
||||||
'game2', self.make_request_message('foo'))
|
|
||||||
self.assertEqual(bot.invites['foo bar baz']['stream'], 'test2')
|
self.assertEqual(bot.invites['foo bar baz']['stream'], 'test2')
|
||||||
|
|
|
@ -6,13 +6,9 @@ from zulip_bots.game_handler import BadMoveException, GameAdapter
|
||||||
|
|
||||||
class GameOfFifteenModel:
|
class GameOfFifteenModel:
|
||||||
|
|
||||||
final_board = [[0, 1, 2],
|
final_board = [[0, 1, 2], [3, 4, 5], [6, 7, 8]]
|
||||||
[3, 4, 5],
|
|
||||||
[6, 7, 8]]
|
|
||||||
|
|
||||||
initial_board = [[8, 7, 6],
|
initial_board = [[8, 7, 6], [5, 4, 3], [2, 1, 0]]
|
||||||
[5, 4, 3],
|
|
||||||
[2, 1, 0]]
|
|
||||||
|
|
||||||
def __init__(self, board: Any = None) -> None:
|
def __init__(self, board: Any = None) -> None:
|
||||||
if board is not None:
|
if board is not None:
|
||||||
|
@ -41,7 +37,7 @@ class GameOfFifteenModel:
|
||||||
def won(self, board: Any) -> bool:
|
def won(self, board: Any) -> bool:
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
for j in range(3):
|
for j in range(3):
|
||||||
if (board[i][j] != self.final_board[i][j]):
|
if board[i][j] != self.final_board[i][j]:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -67,23 +63,26 @@ class GameOfFifteenModel:
|
||||||
if tile not in coordinates:
|
if tile not in coordinates:
|
||||||
raise BadMoveException('You can only move tiles which exist in the board.')
|
raise BadMoveException('You can only move tiles which exist in the board.')
|
||||||
i, j = coordinates[tile]
|
i, j = coordinates[tile]
|
||||||
if (j-1) > -1 and board[i][j-1] == 0:
|
if (j - 1) > -1 and board[i][j - 1] == 0:
|
||||||
board[i][j-1] = tile
|
board[i][j - 1] = tile
|
||||||
board[i][j] = 0
|
board[i][j] = 0
|
||||||
elif (i-1) > -1 and board[i-1][j] == 0:
|
elif (i - 1) > -1 and board[i - 1][j] == 0:
|
||||||
board[i-1][j] = tile
|
board[i - 1][j] = tile
|
||||||
board[i][j] = 0
|
board[i][j] = 0
|
||||||
elif (j+1) < 3 and board[i][j+1] == 0:
|
elif (j + 1) < 3 and board[i][j + 1] == 0:
|
||||||
board[i][j+1] = tile
|
board[i][j + 1] = tile
|
||||||
board[i][j] = 0
|
board[i][j] = 0
|
||||||
elif (i+1) < 3 and board[i+1][j] == 0:
|
elif (i + 1) < 3 and board[i + 1][j] == 0:
|
||||||
board[i+1][j] = tile
|
board[i + 1][j] = tile
|
||||||
board[i][j] = 0
|
board[i][j] = 0
|
||||||
else:
|
else:
|
||||||
raise BadMoveException('You can only move tiles which are adjacent to :grey_question:.')
|
raise BadMoveException(
|
||||||
|
'You can only move tiles which are adjacent to :grey_question:.'
|
||||||
|
)
|
||||||
if m == moves - 1:
|
if m == moves - 1:
|
||||||
return board
|
return board
|
||||||
|
|
||||||
|
|
||||||
class GameOfFifteenMessageHandler:
|
class GameOfFifteenMessageHandler:
|
||||||
|
|
||||||
tiles = {
|
tiles = {
|
||||||
|
@ -113,8 +112,11 @@ class GameOfFifteenMessageHandler:
|
||||||
return original_player + ' moved ' + tile
|
return original_player + ' moved ' + tile
|
||||||
|
|
||||||
def game_start_message(self) -> str:
|
def game_start_message(self) -> str:
|
||||||
return ("Welcome to Game of Fifteen!"
|
return (
|
||||||
"To make a move, type @-mention `move <tile1> <tile2> ...`")
|
"Welcome to Game of Fifteen!"
|
||||||
|
"To make a move, type @-mention `move <tile1> <tile2> ...`"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GameOfFifteenBotHandler(GameAdapter):
|
class GameOfFifteenBotHandler(GameAdapter):
|
||||||
'''
|
'''
|
||||||
|
@ -125,7 +127,9 @@ class GameOfFifteenBotHandler(GameAdapter):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
game_name = 'Game of Fifteen'
|
game_name = 'Game of Fifteen'
|
||||||
bot_name = 'Game of Fifteen'
|
bot_name = 'Game of Fifteen'
|
||||||
move_help_message = '* To make your move during a game, type\n```move <tile1> <tile2> ...```'
|
move_help_message = (
|
||||||
|
'* To make your move during a game, type\n```move <tile1> <tile2> ...```'
|
||||||
|
)
|
||||||
move_regex = r'move [\d{1}\s]+$'
|
move_regex = r'move [\d{1}\s]+$'
|
||||||
model = GameOfFifteenModel
|
model = GameOfFifteenModel
|
||||||
gameMessageHandler = GameOfFifteenMessageHandler
|
gameMessageHandler = GameOfFifteenMessageHandler
|
||||||
|
@ -145,4 +149,5 @@ class GameOfFifteenBotHandler(GameAdapter):
|
||||||
max_players=1,
|
max_players=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
handler_class = GameOfFifteenBotHandler
|
handler_class = GameOfFifteenBotHandler
|
||||||
|
|
|
@ -9,20 +9,19 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests):
|
||||||
bot_name = 'game_of_fifteen'
|
bot_name = 'game_of_fifteen'
|
||||||
|
|
||||||
def make_request_message(
|
def make_request_message(
|
||||||
self,
|
self, content: str, user: str = 'foo@example.com', user_name: str = 'foo'
|
||||||
content: str,
|
|
||||||
user: str = 'foo@example.com',
|
|
||||||
user_name: str = 'foo'
|
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
message = dict(
|
message = dict(sender_email=user, content=content, sender_full_name=user_name)
|
||||||
sender_email=user,
|
|
||||||
content=content,
|
|
||||||
sender_full_name=user_name
|
|
||||||
)
|
|
||||||
return message
|
return message
|
||||||
|
|
||||||
# Function that serves similar purpose to BotTestCase.verify_dialog, but allows for multiple responses to be handled
|
# Function that serves similar purpose to BotTestCase.verify_dialog, but allows for multiple responses to be handled
|
||||||
def verify_response(self, request: str, expected_response: str, response_number: int, user: str = 'foo@example.com') -> None:
|
def verify_response(
|
||||||
|
self,
|
||||||
|
request: str,
|
||||||
|
expected_response: str,
|
||||||
|
response_number: int,
|
||||||
|
user: str = 'foo@example.com',
|
||||||
|
) -> None:
|
||||||
'''
|
'''
|
||||||
This function serves a similar purpose
|
This function serves a similar purpose
|
||||||
to BotTestCase.verify_dialog, but allows
|
to BotTestCase.verify_dialog, but allows
|
||||||
|
@ -36,11 +35,7 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests):
|
||||||
|
|
||||||
bot.handle_message(message, bot_handler)
|
bot.handle_message(message, bot_handler)
|
||||||
|
|
||||||
responses = [
|
responses = [message for (method, message) in bot_handler.transcript]
|
||||||
message
|
|
||||||
for (method, message)
|
|
||||||
in bot_handler.transcript
|
|
||||||
]
|
|
||||||
|
|
||||||
first_response = responses[response_number]
|
first_response = responses[response_number]
|
||||||
self.assertEqual(expected_response, first_response['content'])
|
self.assertEqual(expected_response, first_response['content'])
|
||||||
|
@ -63,23 +58,19 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests):
|
||||||
def test_game_message_handler_responses(self) -> None:
|
def test_game_message_handler_responses(self) -> None:
|
||||||
board = '\n\n:grey_question::one::two:\n\n:three::four::five:\n\n:six::seven::eight:'
|
board = '\n\n:grey_question::one::two:\n\n:three::four::five:\n\n:six::seven::eight:'
|
||||||
bot, bot_handler = self._get_handlers()
|
bot, bot_handler = self._get_handlers()
|
||||||
self.assertEqual(bot.gameMessageHandler.parse_board(
|
self.assertEqual(bot.gameMessageHandler.parse_board(self.winning_board), board)
|
||||||
self.winning_board), board)
|
self.assertEqual(bot.gameMessageHandler.alert_move_message('foo', 'move 1'), 'foo moved 1')
|
||||||
self.assertEqual(bot.gameMessageHandler.alert_move_message(
|
self.assertEqual(
|
||||||
'foo', 'move 1'), 'foo moved 1')
|
bot.gameMessageHandler.game_start_message(),
|
||||||
self.assertEqual(bot.gameMessageHandler.game_start_message(
|
"Welcome to Game of Fifteen!"
|
||||||
), "Welcome to Game of Fifteen!"
|
"To make a move, type @-mention `move <tile1> <tile2> ...`",
|
||||||
"To make a move, type @-mention `move <tile1> <tile2> ...`")
|
)
|
||||||
|
|
||||||
winning_board = [[0, 1, 2],
|
winning_board = [[0, 1, 2], [3, 4, 5], [6, 7, 8]]
|
||||||
[3, 4, 5],
|
|
||||||
[6, 7, 8]]
|
|
||||||
|
|
||||||
def test_game_of_fifteen_logic(self) -> None:
|
def test_game_of_fifteen_logic(self) -> None:
|
||||||
def confirmAvailableMoves(
|
def confirmAvailableMoves(
|
||||||
good_moves: List[int],
|
good_moves: List[int], bad_moves: List[int], board: List[List[int]]
|
||||||
bad_moves: List[int],
|
|
||||||
board: List[List[int]]
|
|
||||||
) -> None:
|
) -> None:
|
||||||
gameOfFifteenModel.update_board(board)
|
gameOfFifteenModel.update_board(board)
|
||||||
for move in good_moves:
|
for move in good_moves:
|
||||||
|
@ -92,18 +83,16 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests):
|
||||||
tile: str,
|
tile: str,
|
||||||
token_number: int,
|
token_number: int,
|
||||||
initial_board: List[List[int]],
|
initial_board: List[List[int]],
|
||||||
final_board: List[List[int]]
|
final_board: List[List[int]],
|
||||||
) -> None:
|
) -> None:
|
||||||
gameOfFifteenModel.update_board(initial_board)
|
gameOfFifteenModel.update_board(initial_board)
|
||||||
test_board = gameOfFifteenModel.make_move(
|
test_board = gameOfFifteenModel.make_move('move ' + tile, token_number)
|
||||||
'move ' + tile, token_number)
|
|
||||||
|
|
||||||
self.assertEqual(test_board, final_board)
|
self.assertEqual(test_board, final_board)
|
||||||
|
|
||||||
def confirmGameOver(board: List[List[int]], result: str) -> None:
|
def confirmGameOver(board: List[List[int]], result: str) -> None:
|
||||||
gameOfFifteenModel.update_board(board)
|
gameOfFifteenModel.update_board(board)
|
||||||
game_over = gameOfFifteenModel.determine_game_over(
|
game_over = gameOfFifteenModel.determine_game_over(['first_player'])
|
||||||
['first_player'])
|
|
||||||
|
|
||||||
self.assertEqual(game_over, result)
|
self.assertEqual(game_over, result)
|
||||||
|
|
||||||
|
@ -115,62 +104,43 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests):
|
||||||
gameOfFifteenModel = GameOfFifteenModel()
|
gameOfFifteenModel = GameOfFifteenModel()
|
||||||
|
|
||||||
# Basic Board setups
|
# Basic Board setups
|
||||||
initial_board = [[8, 7, 6],
|
initial_board = [[8, 7, 6], [5, 4, 3], [2, 1, 0]]
|
||||||
[5, 4, 3],
|
|
||||||
[2, 1, 0]]
|
|
||||||
|
|
||||||
sample_board = [[7, 6, 8],
|
sample_board = [[7, 6, 8], [3, 0, 1], [2, 4, 5]]
|
||||||
[3, 0, 1],
|
|
||||||
[2, 4, 5]]
|
|
||||||
|
|
||||||
winning_board = [[0, 1, 2],
|
winning_board = [[0, 1, 2], [3, 4, 5], [6, 7, 8]]
|
||||||
[3, 4, 5],
|
|
||||||
[6, 7, 8]]
|
|
||||||
|
|
||||||
# Test Move Validation Logic
|
# Test Move Validation Logic
|
||||||
confirmAvailableMoves([1, 2, 3, 4, 5, 6, 7, 8], [0, 9, -1], initial_board)
|
confirmAvailableMoves([1, 2, 3, 4, 5, 6, 7, 8], [0, 9, -1], initial_board)
|
||||||
|
|
||||||
# Test Move Logic
|
# Test Move Logic
|
||||||
confirmMove('1', 0, initial_board,
|
confirmMove('1', 0, initial_board, [[8, 7, 6], [5, 4, 3], [2, 0, 1]])
|
||||||
[[8, 7, 6],
|
|
||||||
[5, 4, 3],
|
|
||||||
[2, 0, 1]])
|
|
||||||
|
|
||||||
confirmMove('1 2', 0, initial_board,
|
confirmMove('1 2', 0, initial_board, [[8, 7, 6], [5, 4, 3], [0, 2, 1]])
|
||||||
[[8, 7, 6],
|
|
||||||
[5, 4, 3],
|
|
||||||
[0, 2, 1]])
|
|
||||||
|
|
||||||
confirmMove('1 2 5', 0, initial_board,
|
confirmMove('1 2 5', 0, initial_board, [[8, 7, 6], [0, 4, 3], [5, 2, 1]])
|
||||||
[[8, 7, 6],
|
|
||||||
[0, 4, 3],
|
|
||||||
[5, 2, 1]])
|
|
||||||
|
|
||||||
confirmMove('1 2 5 4', 0, initial_board,
|
confirmMove('1 2 5 4', 0, initial_board, [[8, 7, 6], [4, 0, 3], [5, 2, 1]])
|
||||||
[[8, 7, 6],
|
|
||||||
[4, 0, 3],
|
|
||||||
[5, 2, 1]])
|
|
||||||
|
|
||||||
confirmMove('3', 0, sample_board,
|
confirmMove('3', 0, sample_board, [[7, 6, 8], [0, 3, 1], [2, 4, 5]])
|
||||||
[[7, 6, 8],
|
|
||||||
[0, 3, 1],
|
|
||||||
[2, 4, 5]])
|
|
||||||
|
|
||||||
confirmMove('3 7', 0, sample_board,
|
confirmMove('3 7', 0, sample_board, [[0, 6, 8], [7, 3, 1], [2, 4, 5]])
|
||||||
[[0, 6, 8],
|
|
||||||
[7, 3, 1],
|
|
||||||
[2, 4, 5]])
|
|
||||||
|
|
||||||
# Test coordinates logic:
|
# Test coordinates logic:
|
||||||
confirm_coordinates(initial_board, {8: (0, 0),
|
confirm_coordinates(
|
||||||
7: (0, 1),
|
initial_board,
|
||||||
6: (0, 2),
|
{
|
||||||
5: (1, 0),
|
8: (0, 0),
|
||||||
4: (1, 1),
|
7: (0, 1),
|
||||||
3: (1, 2),
|
6: (0, 2),
|
||||||
2: (2, 0),
|
5: (1, 0),
|
||||||
1: (2, 1),
|
4: (1, 1),
|
||||||
0: (2, 2)})
|
3: (1, 2),
|
||||||
|
2: (2, 0),
|
||||||
|
1: (2, 1),
|
||||||
|
0: (2, 2),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Test Game Over Logic:
|
# Test Game Over Logic:
|
||||||
confirmGameOver(winning_board, 'current turn')
|
confirmGameOver(winning_board, 'current turn')
|
||||||
|
@ -183,9 +153,7 @@ class TestGameOfFifteenBot(BotTestCase, DefaultTests):
|
||||||
move3 = 'move 23'
|
move3 = 'move 23'
|
||||||
move4 = 'move 0'
|
move4 = 'move 0'
|
||||||
move5 = 'move 1 2'
|
move5 = 'move 1 2'
|
||||||
initial_board = [[8, 7, 6],
|
initial_board = [[8, 7, 6], [5, 4, 3], [2, 1, 0]]
|
||||||
[5, 4, 3],
|
|
||||||
[2, 1, 0]]
|
|
||||||
|
|
||||||
model.update_board(initial_board)
|
model.update_board(initial_board)
|
||||||
with self.assertRaises(BadMoveException):
|
with self.assertRaises(BadMoveException):
|
||||||
|
|
|
@ -19,6 +19,7 @@ class GiphyHandler:
|
||||||
and responds with a message with the GIF based on provided keywords.
|
and responds with a message with the GIF based on provided keywords.
|
||||||
It also responds to private messages.
|
It also responds to private messages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def usage(self) -> str:
|
def usage(self) -> str:
|
||||||
return '''
|
return '''
|
||||||
This plugin allows users to post GIFs provided by Giphy.
|
This plugin allows users to post GIFs provided by Giphy.
|
||||||
|
@ -28,8 +29,7 @@ class GiphyHandler:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def validate_config(config_info: Dict[str, str]) -> None:
|
def validate_config(config_info: Dict[str, str]) -> None:
|
||||||
query = {'s': 'Hello',
|
query = {'s': 'Hello', 'api_key': config_info['key']}
|
||||||
'api_key': config_info['key']}
|
|
||||||
try:
|
try:
|
||||||
data = requests.get(GIPHY_TRANSLATE_API, params=query)
|
data = requests.get(GIPHY_TRANSLATE_API, params=query)
|
||||||
data.raise_for_status()
|
data.raise_for_status()
|
||||||
|
@ -38,19 +38,17 @@ class GiphyHandler:
|
||||||
except HTTPError as e:
|
except HTTPError as e:
|
||||||
error_message = str(e)
|
error_message = str(e)
|
||||||
if data.status_code == 403:
|
if data.status_code == 403:
|
||||||
error_message += ('This is likely due to an invalid key.\n'
|
error_message += (
|
||||||
'Follow the instructions in doc.md for setting an API key.')
|
'This is likely due to an invalid key.\n'
|
||||||
|
'Follow the instructions in doc.md for setting an API key.'
|
||||||
|
)
|
||||||
raise ConfigValidationError(error_message)
|
raise ConfigValidationError(error_message)
|
||||||
|
|
||||||
def initialize(self, bot_handler: BotHandler) -> None:
|
def initialize(self, bot_handler: BotHandler) -> None:
|
||||||
self.config_info = bot_handler.get_config_info('giphy')
|
self.config_info = bot_handler.get_config_info('giphy')
|
||||||
|
|
||||||
def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None:
|
def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None:
|
||||||
bot_response = get_bot_giphy_response(
|
bot_response = get_bot_giphy_response(message, bot_handler, self.config_info)
|
||||||
message,
|
|
||||||
bot_handler,
|
|
||||||
self.config_info
|
|
||||||
)
|
|
||||||
bot_handler.send_reply(message, bot_response)
|
bot_handler.send_reply(message, bot_response)
|
||||||
|
|
||||||
|
|
||||||
|
@ -83,21 +81,26 @@ def get_url_gif_giphy(keyword: str, api_key: str) -> Union[int, str]:
|
||||||
return gif_url
|
return gif_url
|
||||||
|
|
||||||
|
|
||||||
def get_bot_giphy_response(message: Dict[str, str], bot_handler: BotHandler, config_info: Dict[str, str]) -> str:
|
def get_bot_giphy_response(
|
||||||
|
message: Dict[str, str], bot_handler: BotHandler, config_info: Dict[str, str]
|
||||||
|
) -> str:
|
||||||
# Each exception has a specific reply should "gif_url" return a number.
|
# Each exception has a specific reply should "gif_url" return a number.
|
||||||
# The bot will post the appropriate message for the error.
|
# The bot will post the appropriate message for the error.
|
||||||
keyword = message['content']
|
keyword = message['content']
|
||||||
try:
|
try:
|
||||||
gif_url = get_url_gif_giphy(keyword, config_info['key'])
|
gif_url = get_url_gif_giphy(keyword, config_info['key'])
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
return ('Uh oh, sorry :slightly_frowning_face:, I '
|
return (
|
||||||
'cannot process your request right now. But, '
|
'Uh oh, sorry :slightly_frowning_face:, I '
|
||||||
'let\'s try again later! :grin:')
|
'cannot process your request right now. But, '
|
||||||
|
'let\'s try again later! :grin:'
|
||||||
|
)
|
||||||
except GiphyNoResultException:
|
except GiphyNoResultException:
|
||||||
return ('Sorry, I don\'t have a GIF for "%s"! '
|
return 'Sorry, I don\'t have a GIF for "%s"! ' ':astonished:' % (keyword,)
|
||||||
':astonished:' % (keyword,))
|
return (
|
||||||
return ('[Click to enlarge](%s)'
|
'[Click to enlarge](%s)'
|
||||||
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)'
|
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)' % (gif_url,)
|
||||||
% (gif_url,))
|
)
|
||||||
|
|
||||||
|
|
||||||
handler_class = GiphyHandler
|
handler_class = GiphyHandler
|
||||||
|
|
|
@ -10,25 +10,28 @@ class TestGiphyBot(BotTestCase, DefaultTests):
|
||||||
|
|
||||||
# Test for bot response to empty message
|
# Test for bot response to empty message
|
||||||
def test_bot_responds_to_empty_message(self) -> None:
|
def test_bot_responds_to_empty_message(self) -> None:
|
||||||
bot_response = '[Click to enlarge]' \
|
bot_response = (
|
||||||
'(https://media0.giphy.com/media/ISumMYQyX4sSI/giphy.gif)' \
|
'[Click to enlarge]'
|
||||||
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)'
|
'(https://media0.giphy.com/media/ISumMYQyX4sSI/giphy.gif)'
|
||||||
with self.mock_config_info({'key': '12345678'}), \
|
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)'
|
||||||
self.mock_http_conversation('test_random'):
|
)
|
||||||
|
with self.mock_config_info({'key': '12345678'}), self.mock_http_conversation('test_random'):
|
||||||
self.verify_reply('', bot_response)
|
self.verify_reply('', bot_response)
|
||||||
|
|
||||||
def test_normal(self) -> None:
|
def test_normal(self) -> None:
|
||||||
bot_response = '[Click to enlarge]' \
|
bot_response = (
|
||||||
'(https://media4.giphy.com/media/3o6ZtpxSZbQRRnwCKQ/giphy.gif)' \
|
'[Click to enlarge]'
|
||||||
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)'
|
'(https://media4.giphy.com/media/3o6ZtpxSZbQRRnwCKQ/giphy.gif)'
|
||||||
|
'[](/static/images/interactive-bot/giphy/powered-by-giphy.png)'
|
||||||
|
)
|
||||||
|
|
||||||
with self.mock_config_info({'key': '12345678'}), \
|
with self.mock_config_info({'key': '12345678'}), self.mock_http_conversation('test_normal'):
|
||||||
self.mock_http_conversation('test_normal'):
|
|
||||||
self.verify_reply('Hello', bot_response)
|
self.verify_reply('Hello', bot_response)
|
||||||
|
|
||||||
def test_no_result(self) -> None:
|
def test_no_result(self) -> None:
|
||||||
with self.mock_config_info({'key': '12345678'}), \
|
with self.mock_config_info({'key': '12345678'}), self.mock_http_conversation(
|
||||||
self.mock_http_conversation('test_no_result'):
|
'test_no_result'
|
||||||
|
):
|
||||||
self.verify_reply(
|
self.verify_reply(
|
||||||
'world without zulip',
|
'world without zulip',
|
||||||
'Sorry, I don\'t have a GIF for "world without zulip"! :astonished:',
|
'Sorry, I don\'t have a GIF for "world without zulip"! :astonished:',
|
||||||
|
@ -38,8 +41,9 @@ class TestGiphyBot(BotTestCase, DefaultTests):
|
||||||
get_bot_message_handler(self.bot_name)
|
get_bot_message_handler(self.bot_name)
|
||||||
StubBotHandler()
|
StubBotHandler()
|
||||||
with self.mock_http_conversation('test_403'):
|
with self.mock_http_conversation('test_403'):
|
||||||
self.validate_invalid_config({'key': '12345678'},
|
self.validate_invalid_config(
|
||||||
"This is likely due to an invalid key.\n")
|
{'key': '12345678'}, "This is likely due to an invalid key.\n"
|
||||||
|
)
|
||||||
|
|
||||||
def test_connection_error_when_validate_config(self) -> None:
|
def test_connection_error_when_validate_config(self) -> None:
|
||||||
error = ConnectionError()
|
error = ConnectionError()
|
||||||
|
@ -53,11 +57,12 @@ class TestGiphyBot(BotTestCase, DefaultTests):
|
||||||
self.validate_valid_config({'key': '12345678'})
|
self.validate_valid_config({'key': '12345678'})
|
||||||
|
|
||||||
def test_connection_error_while_running(self) -> None:
|
def test_connection_error_while_running(self) -> None:
|
||||||
with self.mock_config_info({'key': '12345678'}), \
|
with self.mock_config_info({'key': '12345678'}), patch(
|
||||||
patch('requests.get', side_effect=[ConnectionError()]), \
|
'requests.get', side_effect=[ConnectionError()]
|
||||||
patch('logging.exception'):
|
), patch('logging.exception'):
|
||||||
self.verify_reply(
|
self.verify_reply(
|
||||||
'world without chocolate',
|
'world without chocolate',
|
||||||
'Uh oh, sorry :slightly_frowning_face:, I '
|
'Uh oh, sorry :slightly_frowning_face:, I '
|
||||||
'cannot process your request right now. But, '
|
'cannot process your request right now. But, '
|
||||||
'let\'s try again later! :grin:')
|
'let\'s try again later! :grin:',
|
||||||
|
)
|
||||||
|
|
|
@ -22,11 +22,13 @@ class GithubHandler:
|
||||||
self.repo = self.config_info.get("repo", False)
|
self.repo = self.config_info.get("repo", False)
|
||||||
|
|
||||||
def usage(self) -> str:
|
def usage(self) -> str:
|
||||||
return ("This plugin displays details on github issues and pull requests. "
|
return (
|
||||||
"To reference an issue or pull request usename mention the bot then "
|
"This plugin displays details on github issues and pull requests. "
|
||||||
"anytime in the message type its id, for example:\n"
|
"To reference an issue or pull request usename mention the bot then "
|
||||||
"@**Github detail** #3212 zulip#3212 zulip/zulip#3212\n"
|
"anytime in the message type its id, for example:\n"
|
||||||
"The default owner is {} and the default repo is {}.".format(self.owner, self.repo))
|
"@**Github detail** #3212 zulip#3212 zulip/zulip#3212\n"
|
||||||
|
"The default owner is {} and the default repo is {}.".format(self.owner, self.repo)
|
||||||
|
)
|
||||||
|
|
||||||
def format_message(self, details: Dict[str, Any]) -> str:
|
def format_message(self, details: Dict[str, Any]) -> str:
|
||||||
number = details['number']
|
number = details['number']
|
||||||
|
@ -39,17 +41,24 @@ class GithubHandler:
|
||||||
description = details['body']
|
description = details['body']
|
||||||
status = details['state'].title()
|
status = details['state'].title()
|
||||||
|
|
||||||
message_string = ('**[{owner}/{repo}#{id}]'.format(owner=owner, repo=repo, id=number),
|
message_string = (
|
||||||
'({link}) - {title}**\n'.format(title=title, link=link),
|
'**[{owner}/{repo}#{id}]'.format(owner=owner, repo=repo, id=number),
|
||||||
'Created by **[{author}](https://github.com/{author})**\n'.format(author=author),
|
'({link}) - {title}**\n'.format(title=title, link=link),
|
||||||
'Status - **{status}**\n```quote\n{description}\n```'.format(status=status, description=description))
|
'Created by **[{author}](https://github.com/{author})**\n'.format(author=author),
|
||||||
|
'Status - **{status}**\n```quote\n{description}\n```'.format(
|
||||||
|
status=status, description=description
|
||||||
|
),
|
||||||
|
)
|
||||||
return ''.join(message_string)
|
return ''.join(message_string)
|
||||||
|
|
||||||
def get_details_from_github(self, owner: str, repo: str, number: str) -> Union[None, Dict[str, Union[str, int, bool]]]:
|
def get_details_from_github(
|
||||||
|
self, owner: str, repo: str, number: str
|
||||||
|
) -> Union[None, Dict[str, Union[str, int, bool]]]:
|
||||||
# Gets the details of an issues or pull request
|
# Gets the details of an issues or pull request
|
||||||
try:
|
try:
|
||||||
r = requests.get(
|
r = requests.get(
|
||||||
self.GITHUB_ISSUE_URL_TEMPLATE.format(owner=owner, repo=repo, id=number))
|
self.GITHUB_ISSUE_URL_TEMPLATE.format(owner=owner, repo=repo, id=number)
|
||||||
|
)
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
logging.exception(str(e))
|
logging.exception(str(e))
|
||||||
return None
|
return None
|
||||||
|
@ -73,8 +82,7 @@ class GithubHandler:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Capture owner, repo, id
|
# Capture owner, repo, id
|
||||||
issue_prs = list(re.finditer(
|
issue_prs = list(re.finditer(self.HANDLE_MESSAGE_REGEX, message['content']))
|
||||||
self.HANDLE_MESSAGE_REGEX, message['content']))
|
|
||||||
bot_messages = []
|
bot_messages = []
|
||||||
if len(issue_prs) > 5:
|
if len(issue_prs) > 5:
|
||||||
# We limit to 5 requests to prevent denial-of-service
|
# We limit to 5 requests to prevent denial-of-service
|
||||||
|
@ -91,8 +99,11 @@ class GithubHandler:
|
||||||
details['repo'] = repo
|
details['repo'] = repo
|
||||||
bot_messages.append(self.format_message(details))
|
bot_messages.append(self.format_message(details))
|
||||||
else:
|
else:
|
||||||
bot_messages.append("Failed to find issue/pr: {owner}/{repo}#{id}"
|
bot_messages.append(
|
||||||
.format(owner=owner, repo=repo, id=issue_pr.group(3)))
|
"Failed to find issue/pr: {owner}/{repo}#{id}".format(
|
||||||
|
owner=owner, repo=repo, id=issue_pr.group(3)
|
||||||
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
bot_messages.append("Failed to detect owner and repository name.")
|
bot_messages.append("Failed to detect owner and repository name.")
|
||||||
if len(bot_messages) == 0:
|
if len(bot_messages) == 0:
|
||||||
|
@ -100,4 +111,5 @@ class GithubHandler:
|
||||||
bot_message = '\n'.join(bot_messages)
|
bot_message = '\n'.join(bot_messages)
|
||||||
bot_handler.send_reply(message, bot_message)
|
bot_handler.send_reply(message, bot_message)
|
||||||
|
|
||||||
|
|
||||||
handler_class = GithubHandler
|
handler_class = GithubHandler
|
||||||
|
|
|
@ -23,15 +23,17 @@ class TestGithubDetailBot(BotTestCase, DefaultTests):
|
||||||
|
|
||||||
def test_issue(self) -> None:
|
def test_issue(self) -> None:
|
||||||
request = 'zulip/zulip#5365'
|
request = 'zulip/zulip#5365'
|
||||||
bot_response = '**[zulip/zulip#5365](https://github.com/zulip/zulip/issues/5365)'\
|
bot_response = (
|
||||||
' - frontend: Enable hot-reloading of CSS in development**\n'\
|
'**[zulip/zulip#5365](https://github.com/zulip/zulip/issues/5365)'
|
||||||
'Created by **[timabbott](https://github.com/timabbott)**\n'\
|
' - frontend: Enable hot-reloading of CSS in development**\n'
|
||||||
'Status - **Open**\n'\
|
'Created by **[timabbott](https://github.com/timabbott)**\n'
|
||||||
'```quote\n'\
|
'Status - **Open**\n'
|
||||||
'There\'s strong interest among folks working on the frontend in being '\
|
'```quote\n'
|
||||||
'able to use the hot-reloading feature of webpack for managing our CSS.\r\n\r\n'\
|
'There\'s strong interest among folks working on the frontend in being '
|
||||||
'In order to do this, step 1 is to move our CSS minification pipeline '\
|
'able to use the hot-reloading feature of webpack for managing our CSS.\r\n\r\n'
|
||||||
'from django-pipeline to Webpack. \n```'
|
'In order to do this, step 1 is to move our CSS minification pipeline '
|
||||||
|
'from django-pipeline to Webpack. \n```'
|
||||||
|
)
|
||||||
|
|
||||||
with self.mock_http_conversation('test_issue'):
|
with self.mock_http_conversation('test_issue'):
|
||||||
with self.mock_config_info(self.mock_config):
|
with self.mock_config_info(self.mock_config):
|
||||||
|
@ -39,18 +41,20 @@ class TestGithubDetailBot(BotTestCase, DefaultTests):
|
||||||
|
|
||||||
def test_pull_request(self) -> None:
|
def test_pull_request(self) -> None:
|
||||||
request = 'zulip/zulip#5345'
|
request = 'zulip/zulip#5345'
|
||||||
bot_response = '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'\
|
bot_response = (
|
||||||
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'\
|
'**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'
|
||||||
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'\
|
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'
|
||||||
'Status - **Open**\n```quote\nAn interaction bug (#4811) '\
|
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'
|
||||||
'between our settings UI and the bootstrap modals breaks hotkey '\
|
'Status - **Open**\n```quote\nAn interaction bug (#4811) '
|
||||||
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'\
|
'between our settings UI and the bootstrap modals breaks hotkey '
|
||||||
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'\
|
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'
|
||||||
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '\
|
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'
|
||||||
'using bootstrap for the account settings modal and all other modals,'\
|
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '
|
||||||
' replace with `Modal` class\r\n[] Add hotkey support for closing the'\
|
'using bootstrap for the account settings modal and all other modals,'
|
||||||
' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'\
|
' replace with `Modal` class\r\n[] Add hotkey support for closing the'
|
||||||
' removing dependencies from Bootstrap.\n```'
|
' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'
|
||||||
|
' removing dependencies from Bootstrap.\n```'
|
||||||
|
)
|
||||||
with self.mock_http_conversation('test_pull'):
|
with self.mock_http_conversation('test_pull'):
|
||||||
with self.mock_config_info(self.mock_config):
|
with self.mock_config_info(self.mock_config):
|
||||||
self.verify_reply(request, bot_response)
|
self.verify_reply(request, bot_response)
|
||||||
|
@ -77,18 +81,22 @@ class TestGithubDetailBot(BotTestCase, DefaultTests):
|
||||||
|
|
||||||
def test_help_text(self) -> None:
|
def test_help_text(self) -> None:
|
||||||
request = 'help'
|
request = 'help'
|
||||||
bot_response = 'This plugin displays details on github issues and pull requests. '\
|
bot_response = (
|
||||||
'To reference an issue or pull request usename mention the bot then '\
|
'This plugin displays details on github issues and pull requests. '
|
||||||
'anytime in the message type its id, for example:\n@**Github detail** '\
|
'To reference an issue or pull request usename mention the bot then '
|
||||||
'#3212 zulip#3212 zulip/zulip#3212\nThe default owner is zulip and '\
|
'anytime in the message type its id, for example:\n@**Github detail** '
|
||||||
'the default repo is zulip.'
|
'#3212 zulip#3212 zulip/zulip#3212\nThe default owner is zulip and '
|
||||||
|
'the default repo is zulip.'
|
||||||
|
)
|
||||||
|
|
||||||
with self.mock_config_info(self.mock_config):
|
with self.mock_config_info(self.mock_config):
|
||||||
self.verify_reply(request, bot_response)
|
self.verify_reply(request, bot_response)
|
||||||
|
|
||||||
def test_too_many_request(self) -> None:
|
def test_too_many_request(self) -> None:
|
||||||
request = 'zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 '\
|
request = (
|
||||||
'zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 zulip/zulip#1'
|
'zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 '
|
||||||
|
'zulip/zulip#1 zulip/zulip#1 zulip/zulip#1 zulip/zulip#1'
|
||||||
|
)
|
||||||
bot_response = 'Please ask for <=5 links in any one request'
|
bot_response = 'Please ask for <=5 links in any one request'
|
||||||
|
|
||||||
with self.mock_config_info(self.mock_config):
|
with self.mock_config_info(self.mock_config):
|
||||||
|
@ -102,36 +110,40 @@ class TestGithubDetailBot(BotTestCase, DefaultTests):
|
||||||
|
|
||||||
def test_owner_and_repo_specified_in_config_file(self) -> None:
|
def test_owner_and_repo_specified_in_config_file(self) -> None:
|
||||||
request = '/#5345'
|
request = '/#5345'
|
||||||
bot_response = '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'\
|
bot_response = (
|
||||||
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'\
|
'**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'
|
||||||
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'\
|
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'
|
||||||
'Status - **Open**\n```quote\nAn interaction bug (#4811) '\
|
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'
|
||||||
'between our settings UI and the bootstrap modals breaks hotkey '\
|
'Status - **Open**\n```quote\nAn interaction bug (#4811) '
|
||||||
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'\
|
'between our settings UI and the bootstrap modals breaks hotkey '
|
||||||
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'\
|
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'
|
||||||
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '\
|
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'
|
||||||
'using bootstrap for the account settings modal and all other modals,'\
|
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '
|
||||||
' replace with `Modal` class\r\n[] Add hotkey support for closing the'\
|
'using bootstrap for the account settings modal and all other modals,'
|
||||||
' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'\
|
' replace with `Modal` class\r\n[] Add hotkey support for closing the'
|
||||||
' removing dependencies from Bootstrap.\n```'
|
' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'
|
||||||
|
' removing dependencies from Bootstrap.\n```'
|
||||||
|
)
|
||||||
with self.mock_http_conversation('test_pull'):
|
with self.mock_http_conversation('test_pull'):
|
||||||
with self.mock_config_info(self.mock_config):
|
with self.mock_config_info(self.mock_config):
|
||||||
self.verify_reply(request, bot_response)
|
self.verify_reply(request, bot_response)
|
||||||
|
|
||||||
def test_owner_and_repo_specified_in_message(self) -> None:
|
def test_owner_and_repo_specified_in_message(self) -> None:
|
||||||
request = 'zulip/zulip#5345'
|
request = 'zulip/zulip#5345'
|
||||||
bot_response = '**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'\
|
bot_response = (
|
||||||
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'\
|
'**[zulip/zulip#5345](https://github.com/zulip/zulip/pull/5345)'
|
||||||
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'\
|
' - [WIP] modal: Replace bootstrap modal with custom modal class**\n'
|
||||||
'Status - **Open**\n```quote\nAn interaction bug (#4811) '\
|
'Created by **[jackrzhang](https://github.com/jackrzhang)**\n'
|
||||||
'between our settings UI and the bootstrap modals breaks hotkey '\
|
'Status - **Open**\n```quote\nAn interaction bug (#4811) '
|
||||||
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'\
|
'between our settings UI and the bootstrap modals breaks hotkey '
|
||||||
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'\
|
'support for `Esc` when multiple modals are open.\r\n\r\ntodo:\r\n[x]'
|
||||||
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '\
|
' Create `Modal` class in `modal.js` (drafted by @brockwhittaker)\r\n[x]'
|
||||||
'using bootstrap for the account settings modal and all other modals,'\
|
' Reimplement change_email_modal utilizing `Modal` class\r\n[] Dump '
|
||||||
' replace with `Modal` class\r\n[] Add hotkey support for closing the'\
|
'using bootstrap for the account settings modal and all other modals,'
|
||||||
' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'\
|
' replace with `Modal` class\r\n[] Add hotkey support for closing the'
|
||||||
' removing dependencies from Bootstrap.\n```'
|
' top modal for `Esc`\r\n\r\nThis should also be a helpful step in'
|
||||||
|
' removing dependencies from Bootstrap.\n```'
|
||||||
|
)
|
||||||
with self.mock_http_conversation('test_pull'):
|
with self.mock_http_conversation('test_pull'):
|
||||||
with self.mock_config_info(self.empty_config):
|
with self.mock_config_info(self.empty_config):
|
||||||
self.verify_reply(request, bot_response)
|
self.verify_reply(request, bot_response)
|
||||||
|
|
|
@ -32,11 +32,11 @@ def google_search(keywords: str) -> List[Dict[str, str]]:
|
||||||
if a.text.strip() == 'Cached' and 'webcache.googleusercontent.com' in a['href']:
|
if a.text.strip() == 'Cached' and 'webcache.googleusercontent.com' in a['href']:
|
||||||
continue
|
continue
|
||||||
# a.text: The name of the page
|
# a.text: The name of the page
|
||||||
result = {'url': "https://www.google.com{}".format(link),
|
result = {'url': "https://www.google.com{}".format(link), 'name': a.text}
|
||||||
'name': a.text}
|
|
||||||
results.append(result)
|
results.append(result)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def get_google_result(search_keywords: str) -> str:
|
def get_google_result(search_keywords: str) -> str:
|
||||||
help_message = "To use this bot, start messages with @mentioned-bot, \
|
help_message = "To use this bot, start messages with @mentioned-bot, \
|
||||||
followed by what you want to search for. If \
|
followed by what you want to search for. If \
|
||||||
|
@ -56,13 +56,14 @@ def get_google_result(search_keywords: str) -> str:
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
results = google_search(search_keywords)
|
results = google_search(search_keywords)
|
||||||
if (len(results) == 0):
|
if len(results) == 0:
|
||||||
return "Found no results."
|
return "Found no results."
|
||||||
return "Found Result: [{}]({})".format(results[0]['name'], results[0]['url'])
|
return "Found Result: [{}]({})".format(results[0]['name'], results[0]['url'])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(str(e))
|
logging.exception(str(e))
|
||||||
return 'Error: Search failed. {}.'.format(e)
|
return 'Error: Search failed. {}.'.format(e)
|
||||||
|
|
||||||
|
|
||||||
class GoogleSearchHandler:
|
class GoogleSearchHandler:
|
||||||
'''
|
'''
|
||||||
This plugin allows users to enter a search
|
This plugin allows users to enter a search
|
||||||
|
@ -87,4 +88,5 @@ class GoogleSearchHandler:
|
||||||
result = get_google_result(original_content)
|
result = get_google_result(original_content)
|
||||||
bot_handler.send_reply(message, result)
|
bot_handler.send_reply(message, result)
|
||||||
|
|
||||||
|
|
||||||
handler_class = GoogleSearchHandler
|
handler_class = GoogleSearchHandler
|
||||||
|
|
|
@ -11,7 +11,7 @@ class TestGoogleSearchBot(BotTestCase, DefaultTests):
|
||||||
with self.mock_http_conversation('test_normal'):
|
with self.mock_http_conversation('test_normal'):
|
||||||
self.verify_reply(
|
self.verify_reply(
|
||||||
'zulip',
|
'zulip',
|
||||||
'Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)'
|
'Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)',
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_bot_help(self) -> None:
|
def test_bot_help(self) -> None:
|
||||||
|
@ -31,9 +31,10 @@ class TestGoogleSearchBot(BotTestCase, DefaultTests):
|
||||||
self.verify_reply('no res', 'Found no results.')
|
self.verify_reply('no res', 'Found no results.')
|
||||||
|
|
||||||
def test_attribute_error(self) -> None:
|
def test_attribute_error(self) -> None:
|
||||||
with self.mock_http_conversation('test_attribute_error'), \
|
with self.mock_http_conversation('test_attribute_error'), patch('logging.exception'):
|
||||||
patch('logging.exception'):
|
self.verify_reply(
|
||||||
self.verify_reply('test', 'Error: Search failed. \'NoneType\' object has no attribute \'findAll\'.')
|
'test', 'Error: Search failed. \'NoneType\' object has no attribute \'findAll\'.'
|
||||||
|
)
|
||||||
|
|
||||||
# Makes sure cached results, irrelevant links, or empty results are not displayed
|
# Makes sure cached results, irrelevant links, or empty results are not displayed
|
||||||
def test_ignore_links(self) -> None:
|
def test_ignore_links(self) -> None:
|
||||||
|
@ -43,5 +44,5 @@ class TestGoogleSearchBot(BotTestCase, DefaultTests):
|
||||||
# See test_ignore_links.json
|
# See test_ignore_links.json
|
||||||
self.verify_reply(
|
self.verify_reply(
|
||||||
'zulip',
|
'zulip',
|
||||||
'Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)'
|
'Found Result: [Zulip](https://www.google.com/url?url=https%3A%2F%2Fzulip.com%2F)',
|
||||||
)
|
)
|
||||||
|
|
|
@ -10,6 +10,7 @@ class GoogleTranslateHandler:
|
||||||
Before using it, make sure you set up google api keys, and enable google
|
Before using it, make sure you set up google api keys, and enable google
|
||||||
cloud translate from the google cloud console.
|
cloud translate from the google cloud console.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def usage(self):
|
def usage(self):
|
||||||
return '''
|
return '''
|
||||||
This plugin allows users translate messages
|
This plugin allows users translate messages
|
||||||
|
@ -27,12 +28,15 @@ class GoogleTranslateHandler:
|
||||||
bot_handler.quit(str(e))
|
bot_handler.quit(str(e))
|
||||||
|
|
||||||
def handle_message(self, message, bot_handler):
|
def handle_message(self, message, bot_handler):
|
||||||
bot_response = get_translate_bot_response(message['content'],
|
bot_response = get_translate_bot_response(
|
||||||
self.config_info,
|
message['content'],
|
||||||
message['sender_full_name'],
|
self.config_info,
|
||||||
self.supported_languages)
|
message['sender_full_name'],
|
||||||
|
self.supported_languages,
|
||||||
|
)
|
||||||
bot_handler.send_reply(message, bot_response)
|
bot_handler.send_reply(message, bot_response)
|
||||||
|
|
||||||
|
|
||||||
api_url = 'https://translation.googleapis.com/language/translate/v2'
|
api_url = 'https://translation.googleapis.com/language/translate/v2'
|
||||||
|
|
||||||
help_text = '''
|
help_text = '''
|
||||||
|
@ -44,17 +48,20 @@ Visit [here](https://cloud.google.com/translate/docs/languages) for all language
|
||||||
|
|
||||||
language_not_found_text = '{} language not found. Visit [here](https://cloud.google.com/translate/docs/languages) for all languages'
|
language_not_found_text = '{} language not found. Visit [here](https://cloud.google.com/translate/docs/languages) for all languages'
|
||||||
|
|
||||||
|
|
||||||
def get_supported_languages(key):
|
def get_supported_languages(key):
|
||||||
parameters = {'key': key, 'target': 'en'}
|
parameters = {'key': key, 'target': 'en'}
|
||||||
response = requests.get(api_url + '/languages', params = parameters)
|
response = requests.get(api_url + '/languages', params=parameters)
|
||||||
if response.status_code == requests.codes.ok:
|
if response.status_code == requests.codes.ok:
|
||||||
languages = response.json()['data']['languages']
|
languages = response.json()['data']['languages']
|
||||||
return {lang['name'].lower(): lang['language'].lower() for lang in languages}
|
return {lang['name'].lower(): lang['language'].lower() for lang in languages}
|
||||||
raise TranslateError(response.json()['error']['message'])
|
raise TranslateError(response.json()['error']['message'])
|
||||||
|
|
||||||
|
|
||||||
class TranslateError(Exception):
|
class TranslateError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def translate(text_to_translate, key, dest, src):
|
def translate(text_to_translate, key, dest, src):
|
||||||
parameters = {'q': text_to_translate, 'target': dest, 'key': key}
|
parameters = {'q': text_to_translate, 'target': dest, 'key': key}
|
||||||
if src != '':
|
if src != '':
|
||||||
|
@ -64,6 +71,7 @@ def translate(text_to_translate, key, dest, src):
|
||||||
return response.json()['data']['translations'][0]['translatedText']
|
return response.json()['data']['translations'][0]['translatedText']
|
||||||
raise TranslateError(response.json()['error']['message'])
|
raise TranslateError(response.json()['error']['message'])
|
||||||
|
|
||||||
|
|
||||||
def get_code_for_language(language, all_languages):
|
def get_code_for_language(language, all_languages):
|
||||||
if language.lower() not in all_languages.values():
|
if language.lower() not in all_languages.values():
|
||||||
if language.lower() not in all_languages.keys():
|
if language.lower() not in all_languages.keys():
|
||||||
|
@ -71,6 +79,7 @@ def get_code_for_language(language, all_languages):
|
||||||
language = all_languages[language.lower()]
|
language = all_languages[language.lower()]
|
||||||
return language
|
return language
|
||||||
|
|
||||||
|
|
||||||
def get_translate_bot_response(message_content, config_file, author, all_languages):
|
def get_translate_bot_response(message_content, config_file, author, all_languages):
|
||||||
message_content = message_content.strip()
|
message_content = message_content.strip()
|
||||||
if message_content == 'help' or message_content is None or not message_content.startswith('"'):
|
if message_content == 'help' or message_content is None or not message_content.startswith('"'):
|
||||||
|
@ -94,7 +103,9 @@ def get_translate_bot_response(message_content, config_file, author, all_languag
|
||||||
if source_language == '':
|
if source_language == '':
|
||||||
return language_not_found_text.format("Source")
|
return language_not_found_text.format("Source")
|
||||||
try:
|
try:
|
||||||
translated_text = translate(text_to_translate, config_file['key'], target_language, source_language)
|
translated_text = translate(
|
||||||
|
text_to_translate, config_file['key'], target_language, source_language
|
||||||
|
)
|
||||||
except requests.exceptions.ConnectionError as conn_err:
|
except requests.exceptions.ConnectionError as conn_err:
|
||||||
return "Could not connect to Google Translate. {}.".format(conn_err)
|
return "Could not connect to Google Translate. {}.".format(conn_err)
|
||||||
except TranslateError as tr_err:
|
except TranslateError as tr_err:
|
||||||
|
@ -103,4 +114,5 @@ def get_translate_bot_response(message_content, config_file, author, all_languag
|
||||||
return "Error. {}.".format(err)
|
return "Error. {}.".format(err)
|
||||||
return "{} (from {})".format(translated_text, author)
|
return "{} (from {})".format(translated_text, author)
|
||||||
|
|
||||||
|
|
||||||
handler_class = GoogleTranslateHandler
|
handler_class = GoogleTranslateHandler
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue