2022-05-06 07:04:51 +00:00
|
|
|
import Crypto
|
2018-11-18 14:41:00 +00:00
|
|
|
import asyncio
|
2022-05-06 07:04:51 +00:00
|
|
|
import click
|
|
|
|
import logging
|
|
|
|
import platform
|
2019-06-11 17:48:49 +00:00
|
|
|
|
2022-11-07 14:53:04 +00:00
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
from . import misc, __version__
|
2022-12-29 12:27:35 +00:00
|
|
|
from . import http_client as http
|
2022-11-07 12:54:32 +00:00
|
|
|
from .application import Application
|
2022-12-04 06:20:17 +00:00
|
|
|
from .config import RELAY_SOFTWARE
|
2018-11-18 14:41:00 +00:00
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
|
|
|
|
app = None
|
2022-11-20 11:14:37 +00:00
|
|
|
CONFIG_IGNORE = {'blocked_software', 'blocked_instances', 'whitelist'}
|
2018-11-18 14:41:00 +00:00
|
|
|
|
|
|
|
|
2022-05-06 07:04:51 +00:00
|
|
|
@click.group('cli', context_settings={'show_default': True}, invoke_without_command=True)
|
|
|
|
@click.option('--config', '-c', default='relay.yaml', help='path to the relay\'s config')
|
|
|
|
@click.version_option(version=__version__, prog_name='ActivityRelay')
|
|
|
|
@click.pass_context
|
|
|
|
def cli(ctx, config):
|
2022-11-07 12:54:32 +00:00
|
|
|
global app
|
|
|
|
app = Application(config)
|
2018-11-18 14:41:00 +00:00
|
|
|
|
2022-05-06 07:04:51 +00:00
|
|
|
if not ctx.invoked_subcommand:
|
2022-11-07 12:54:32 +00:00
|
|
|
if app.config.host.endswith('example.com'):
|
2022-11-20 11:24:33 +00:00
|
|
|
cli_setup.callback()
|
2018-11-18 14:41:00 +00:00
|
|
|
|
2022-05-06 07:04:51 +00:00
|
|
|
else:
|
2022-11-20 11:24:33 +00:00
|
|
|
cli_run.callback()
|
|
|
|
|
|
|
|
|
|
|
|
@cli.command('setup')
|
|
|
|
def cli_setup():
|
|
|
|
'Generate a new config'
|
|
|
|
|
|
|
|
while True:
|
|
|
|
app.config.host = click.prompt('What domain will the relay be hosted on?', default=app.config.host)
|
|
|
|
|
2022-11-27 04:01:18 +00:00
|
|
|
if not app.config.host.endswith('example.com'):
|
2022-11-20 11:24:33 +00:00
|
|
|
break
|
|
|
|
|
|
|
|
click.echo('The domain must not be example.com')
|
|
|
|
|
2022-12-08 08:31:47 +00:00
|
|
|
if not app.config.is_docker:
|
|
|
|
app.config.listen = click.prompt('Which address should the relay listen on?', default=app.config.listen)
|
2022-11-20 11:24:33 +00:00
|
|
|
|
2022-12-08 08:31:47 +00:00
|
|
|
while True:
|
|
|
|
app.config.port = click.prompt('What TCP port should the relay listen on?', default=app.config.port, type=int)
|
|
|
|
break
|
2022-11-20 11:24:33 +00:00
|
|
|
|
|
|
|
app.config.save()
|
|
|
|
|
2022-11-27 00:59:20 +00:00
|
|
|
if not app.config.is_docker and click.confirm('Relay all setup! Would you like to run it now?'):
|
2022-11-20 11:24:33 +00:00
|
|
|
cli_run.callback()
|
|
|
|
|
|
|
|
|
|
|
|
@cli.command('run')
|
|
|
|
def cli_run():
|
|
|
|
'Run the relay'
|
|
|
|
|
|
|
|
if app.config.host.endswith('example.com'):
|
|
|
|
return click.echo('Relay is not set up. Please edit your relay config or run "activityrelay setup".')
|
|
|
|
|
|
|
|
vers_split = platform.python_version().split('.')
|
|
|
|
pip_command = 'pip3 uninstall pycrypto && pip3 install pycryptodome'
|
|
|
|
|
|
|
|
if Crypto.__version__ == '2.6.1':
|
|
|
|
if int(vers_split[1]) > 7:
|
|
|
|
click.echo('Error: PyCrypto is broken on Python 3.8+. Please replace it with pycryptodome before running again. Exiting...')
|
|
|
|
return click.echo(pip_command)
|
|
|
|
|
|
|
|
else:
|
|
|
|
click.echo('Warning: PyCrypto is old and should be replaced with pycryptodome')
|
|
|
|
return click.echo(pip_command)
|
|
|
|
|
|
|
|
if not misc.check_open_port(app.config.listen, app.config.port):
|
|
|
|
return click.echo(f'Error: A server is already running on port {app.config.port}')
|
|
|
|
|
|
|
|
app.run()
|
2018-11-18 14:41:00 +00:00
|
|
|
|
|
|
|
|
2022-11-20 11:14:37 +00:00
|
|
|
# todo: add config default command for resetting config key
|
2022-12-04 06:09:45 +00:00
|
|
|
@cli.group('config')
|
|
|
|
def cli_config():
|
|
|
|
'Manage the relay config'
|
|
|
|
pass
|
2022-11-20 11:14:37 +00:00
|
|
|
|
2022-12-04 06:09:45 +00:00
|
|
|
|
|
|
|
@cli_config.command('list')
|
|
|
|
def cli_config_list():
|
|
|
|
'List the current relay config'
|
2022-11-20 11:14:37 +00:00
|
|
|
|
|
|
|
click.echo('Relay Config:')
|
|
|
|
|
|
|
|
for key, value in app.config.items():
|
|
|
|
if key not in CONFIG_IGNORE:
|
|
|
|
key = f'{key}:'.ljust(20)
|
|
|
|
click.echo(f'- {key} {value}')
|
|
|
|
|
|
|
|
|
|
|
|
@cli_config.command('set')
|
|
|
|
@click.argument('key')
|
|
|
|
@click.argument('value')
|
|
|
|
def cli_config_set(key, value):
|
|
|
|
'Set a config value'
|
|
|
|
|
|
|
|
app.config[key] = value
|
|
|
|
app.config.save()
|
|
|
|
|
|
|
|
print(f'{key}: {app.config[key]}')
|
|
|
|
|
|
|
|
|
|
|
|
@cli.group('inbox')
|
|
|
|
def cli_inbox():
|
2022-05-06 07:04:51 +00:00
|
|
|
'Manage the inboxes in the database'
|
|
|
|
pass
|
2018-11-18 14:41:00 +00:00
|
|
|
|
|
|
|
|
2022-05-06 07:04:51 +00:00
|
|
|
@cli_inbox.command('list')
|
|
|
|
def cli_inbox_list():
|
|
|
|
'List the connected instances or relays'
|
2019-06-11 17:48:49 +00:00
|
|
|
|
2022-05-06 07:04:51 +00:00
|
|
|
click.echo('Connected to the following instances or relays:')
|
2019-06-11 17:48:49 +00:00
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
for inbox in app.database.inboxes:
|
2022-05-06 07:04:51 +00:00
|
|
|
click.echo(f'- {inbox}')
|
2019-06-11 17:48:49 +00:00
|
|
|
|
|
|
|
|
2022-05-06 07:04:51 +00:00
|
|
|
@cli_inbox.command('follow')
|
|
|
|
@click.argument('actor')
|
|
|
|
def cli_inbox_follow(actor):
|
|
|
|
'Follow an actor (Relay must be running)'
|
2018-11-18 14:41:00 +00:00
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
if app.config.is_banned(actor):
|
2022-05-06 20:10:34 +00:00
|
|
|
return click.echo(f'Error: Refusing to follow banned actor: {actor}')
|
|
|
|
|
|
|
|
if not actor.startswith('http'):
|
2022-11-07 14:53:04 +00:00
|
|
|
domain = actor
|
2022-05-06 20:10:34 +00:00
|
|
|
actor = f'https://{actor}/actor'
|
|
|
|
|
2022-11-07 14:53:04 +00:00
|
|
|
else:
|
|
|
|
domain = urlparse(actor).hostname
|
|
|
|
|
|
|
|
try:
|
|
|
|
inbox_data = app.database['relay-list'][domain]
|
|
|
|
inbox = inbox_data['inbox']
|
2022-05-06 22:08:55 +00:00
|
|
|
|
2022-11-07 14:53:04 +00:00
|
|
|
except KeyError:
|
2022-12-29 12:27:35 +00:00
|
|
|
actor_data = asyncio.run(http.get(app.database, actor, sign_headers=True))
|
2022-11-21 03:24:36 +00:00
|
|
|
|
|
|
|
if not actor_data:
|
|
|
|
return click.echo(f'Failed to fetch actor: {actor}')
|
|
|
|
|
2022-11-07 14:53:04 +00:00
|
|
|
inbox = actor_data.shared_inbox
|
2022-05-06 22:08:55 +00:00
|
|
|
|
2022-11-07 14:53:04 +00:00
|
|
|
message = misc.Message.new_follow(
|
|
|
|
host = app.config.host,
|
2022-11-21 03:24:36 +00:00
|
|
|
actor = actor
|
2022-11-07 14:53:04 +00:00
|
|
|
)
|
2022-05-06 22:08:55 +00:00
|
|
|
|
2022-12-29 12:27:35 +00:00
|
|
|
asyncio.run(http.post(app.database, inbox, message))
|
2022-05-06 22:08:55 +00:00
|
|
|
click.echo(f'Sent follow message to actor: {actor}')
|
2018-11-18 14:41:00 +00:00
|
|
|
|
|
|
|
|
2022-05-06 07:04:51 +00:00
|
|
|
@cli_inbox.command('unfollow')
|
|
|
|
@click.argument('actor')
|
|
|
|
def cli_inbox_unfollow(actor):
|
|
|
|
'Unfollow an actor (Relay must be running)'
|
2018-11-18 14:41:00 +00:00
|
|
|
|
2022-05-06 20:10:34 +00:00
|
|
|
if not actor.startswith('http'):
|
2022-11-07 14:53:04 +00:00
|
|
|
domain = actor
|
2022-05-06 20:10:34 +00:00
|
|
|
actor = f'https://{actor}/actor'
|
|
|
|
|
2022-11-07 14:53:04 +00:00
|
|
|
else:
|
|
|
|
domain = urlparse(actor).hostname
|
|
|
|
|
|
|
|
try:
|
|
|
|
inbox_data = app.database['relay-list'][domain]
|
|
|
|
inbox = inbox_data['inbox']
|
|
|
|
message = misc.Message.new_unfollow(
|
|
|
|
host = app.config.host,
|
|
|
|
actor = actor,
|
|
|
|
follow = inbox_data['followid']
|
|
|
|
)
|
|
|
|
|
|
|
|
except KeyError:
|
2022-12-29 12:27:35 +00:00
|
|
|
actor_data = asyncio.run(http.get(app.database, actor, sign_headers=True))
|
2022-11-07 14:53:04 +00:00
|
|
|
inbox = actor_data.shared_inbox
|
|
|
|
message = misc.Message.new_unfollow(
|
|
|
|
host = app.config.host,
|
|
|
|
actor = actor,
|
|
|
|
follow = {
|
|
|
|
'type': 'Follow',
|
|
|
|
'object': actor,
|
|
|
|
'actor': f'https://{app.config.host}/actor'
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
2022-12-29 12:27:35 +00:00
|
|
|
asyncio.run(http.post(app.database, inbox, message))
|
2022-11-07 14:53:04 +00:00
|
|
|
click.echo(f'Sent unfollow message to: {actor}')
|
2022-05-06 07:04:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
@cli_inbox.command('add')
|
|
|
|
@click.argument('inbox')
|
|
|
|
def cli_inbox_add(inbox):
|
|
|
|
'Add an inbox to the database'
|
|
|
|
|
|
|
|
if not inbox.startswith('http'):
|
|
|
|
inbox = f'https://{inbox}/inbox'
|
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
if app.config.is_banned(inbox):
|
2022-11-06 02:15:37 +00:00
|
|
|
return click.echo(f'Error: Refusing to add banned inbox: {inbox}')
|
2022-05-06 07:04:51 +00:00
|
|
|
|
2022-11-18 21:57:34 +00:00
|
|
|
if app.database.get_inbox(inbox):
|
|
|
|
return click.echo(f'Error: Inbox already in database: {inbox}')
|
2022-11-06 02:15:37 +00:00
|
|
|
|
2022-11-18 21:57:34 +00:00
|
|
|
app.database.add_inbox(inbox)
|
|
|
|
app.database.save()
|
|
|
|
|
|
|
|
click.echo(f'Added inbox to the database: {inbox}')
|
2022-05-06 07:04:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
@cli_inbox.command('remove')
|
|
|
|
@click.argument('inbox')
|
|
|
|
def cli_inbox_remove(inbox):
|
|
|
|
'Remove an inbox from the database'
|
|
|
|
|
2022-11-06 02:15:37 +00:00
|
|
|
try:
|
2022-11-07 12:54:32 +00:00
|
|
|
dbinbox = app.database.get_inbox(inbox, fail=True)
|
2022-11-06 02:15:37 +00:00
|
|
|
|
|
|
|
except KeyError:
|
2022-05-06 07:04:51 +00:00
|
|
|
click.echo(f'Error: Inbox does not exist: {inbox}')
|
|
|
|
return
|
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
app.database.del_inbox(dbinbox['domain'])
|
|
|
|
app.database.save()
|
2022-11-06 02:15:37 +00:00
|
|
|
|
2022-05-06 07:04:51 +00:00
|
|
|
click.echo(f'Removed inbox from the database: {inbox}')
|
|
|
|
|
|
|
|
|
|
|
|
@cli.group('instance')
|
|
|
|
def cli_instance():
|
|
|
|
'Manage instance bans'
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
@cli_instance.command('list')
|
|
|
|
def cli_instance_list():
|
|
|
|
'List all banned instances'
|
|
|
|
|
|
|
|
click.echo('Banned instances or relays:')
|
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
for domain in app.config.blocked_instances:
|
2022-05-06 07:04:51 +00:00
|
|
|
click.echo(f'- {domain}')
|
|
|
|
|
|
|
|
|
|
|
|
@cli_instance.command('ban')
|
|
|
|
@click.argument('target')
|
|
|
|
def cli_instance_ban(target):
|
|
|
|
'Ban an instance and remove the associated inbox if it exists'
|
|
|
|
|
2022-11-06 02:15:37 +00:00
|
|
|
if target.startswith('http'):
|
|
|
|
target = urlparse(target).hostname
|
2022-05-06 07:04:51 +00:00
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
if app.config.ban_instance(target):
|
|
|
|
app.config.save()
|
2022-05-06 07:04:51 +00:00
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
if app.database.del_inbox(target):
|
|
|
|
app.database.save()
|
2022-05-06 07:04:51 +00:00
|
|
|
|
|
|
|
click.echo(f'Banned instance: {target}')
|
|
|
|
return
|
|
|
|
|
|
|
|
click.echo(f'Instance already banned: {target}')
|
|
|
|
|
|
|
|
|
|
|
|
@cli_instance.command('unban')
|
|
|
|
@click.argument('target')
|
|
|
|
def cli_instance_unban(target):
|
|
|
|
'Unban an instance'
|
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
if app.config.unban_instance(target):
|
|
|
|
app.config.save()
|
2022-05-06 07:04:51 +00:00
|
|
|
|
|
|
|
click.echo(f'Unbanned instance: {target}')
|
|
|
|
return
|
|
|
|
|
|
|
|
click.echo(f'Instance wasn\'t banned: {target}')
|
|
|
|
|
|
|
|
|
|
|
|
@cli.group('software')
|
|
|
|
def cli_software():
|
|
|
|
'Manage banned software'
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
@cli_software.command('list')
|
|
|
|
def cli_software_list():
|
|
|
|
'List all banned software'
|
|
|
|
|
|
|
|
click.echo('Banned software:')
|
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
for software in app.config.blocked_software:
|
2022-05-06 07:04:51 +00:00
|
|
|
click.echo(f'- {software}')
|
|
|
|
|
|
|
|
|
|
|
|
@cli_software.command('ban')
|
|
|
|
@click.option('--fetch-nodeinfo/--ignore-nodeinfo', '-f', 'fetch_nodeinfo', default=False,
|
|
|
|
help='Treat NAME like a domain and try to fet the software name from nodeinfo'
|
|
|
|
)
|
|
|
|
@click.argument('name')
|
|
|
|
def cli_software_ban(name, fetch_nodeinfo):
|
|
|
|
'Ban software. Use RELAYS for NAME to ban relays'
|
|
|
|
|
|
|
|
if name == 'RELAYS':
|
2022-12-04 06:20:17 +00:00
|
|
|
for name in RELAY_SOFTWARE:
|
2022-11-07 12:54:32 +00:00
|
|
|
app.config.ban_software(name)
|
2022-05-06 07:04:51 +00:00
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
app.config.save()
|
2022-05-06 07:04:51 +00:00
|
|
|
return click.echo('Banned all relay software')
|
|
|
|
|
|
|
|
if fetch_nodeinfo:
|
2022-12-29 12:27:35 +00:00
|
|
|
nodeinfo = asyncio.run(http.fetch_nodeinfo(app.database, name))
|
2022-05-06 07:04:51 +00:00
|
|
|
|
2022-12-04 06:09:45 +00:00
|
|
|
if not nodeinfo:
|
2022-05-06 07:04:51 +00:00
|
|
|
click.echo(f'Failed to fetch software name from domain: {name}')
|
|
|
|
|
2022-12-04 06:09:45 +00:00
|
|
|
name = nodeinfo.sw_name
|
|
|
|
|
|
|
|
if app.config.ban_software(name):
|
2022-11-07 12:54:32 +00:00
|
|
|
app.config.save()
|
2022-12-04 06:09:45 +00:00
|
|
|
return click.echo(f'Banned software: {name}')
|
2022-05-06 07:04:51 +00:00
|
|
|
|
2022-12-04 06:09:45 +00:00
|
|
|
click.echo(f'Software already banned: {name}')
|
2022-05-06 07:04:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
@cli_software.command('unban')
|
|
|
|
@click.option('--fetch-nodeinfo/--ignore-nodeinfo', '-f', 'fetch_nodeinfo', default=False,
|
|
|
|
help='Treat NAME like a domain and try to fet the software name from nodeinfo'
|
|
|
|
)
|
|
|
|
@click.argument('name')
|
|
|
|
def cli_software_unban(name, fetch_nodeinfo):
|
|
|
|
'Ban software. Use RELAYS for NAME to unban relays'
|
|
|
|
|
|
|
|
if name == 'RELAYS':
|
2022-12-04 06:20:17 +00:00
|
|
|
for name in RELAY_SOFTWARE:
|
2022-11-07 12:54:32 +00:00
|
|
|
app.config.unban_software(name)
|
2022-05-06 07:04:51 +00:00
|
|
|
|
2022-12-04 06:09:45 +00:00
|
|
|
app.config.save()
|
2022-05-06 07:04:51 +00:00
|
|
|
return click.echo('Unbanned all relay software')
|
|
|
|
|
|
|
|
if fetch_nodeinfo:
|
2022-12-29 12:27:35 +00:00
|
|
|
nodeinfo = asyncio.run(http.fetch_nodeinfo(app.database, name))
|
2022-05-06 07:04:51 +00:00
|
|
|
|
2022-11-18 18:45:26 +00:00
|
|
|
if not nodeinfo:
|
2022-05-06 07:04:51 +00:00
|
|
|
click.echo(f'Failed to fetch software name from domain: {name}')
|
|
|
|
|
2022-12-04 06:09:45 +00:00
|
|
|
name = nodeinfo.sw_name
|
2022-05-06 07:04:51 +00:00
|
|
|
|
2022-12-04 06:09:45 +00:00
|
|
|
if app.config.unban_software(name):
|
|
|
|
app.config.save()
|
|
|
|
return click.echo(f'Unbanned software: {name}')
|
2018-11-18 14:41:00 +00:00
|
|
|
|
2022-12-04 06:09:45 +00:00
|
|
|
click.echo(f'Software wasn\'t banned: {name}')
|
2018-11-18 14:41:00 +00:00
|
|
|
|
2022-05-06 07:04:51 +00:00
|
|
|
|
|
|
|
@cli.group('whitelist')
|
|
|
|
def cli_whitelist():
|
|
|
|
'Manage the instance whitelist'
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
@cli_whitelist.command('list')
|
|
|
|
def cli_whitelist_list():
|
2022-12-04 06:09:45 +00:00
|
|
|
'List all the instances in the whitelist'
|
|
|
|
|
2022-05-06 07:04:51 +00:00
|
|
|
click.echo('Current whitelisted domains')
|
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
for domain in app.config.whitelist:
|
2022-05-06 07:04:51 +00:00
|
|
|
click.echo(f'- {domain}')
|
|
|
|
|
|
|
|
|
|
|
|
@cli_whitelist.command('add')
|
|
|
|
@click.argument('instance')
|
|
|
|
def cli_whitelist_add(instance):
|
|
|
|
'Add an instance to the whitelist'
|
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
if not app.config.add_whitelist(instance):
|
2022-05-06 07:04:51 +00:00
|
|
|
return click.echo(f'Instance already in the whitelist: {instance}')
|
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
app.config.save()
|
2022-05-06 07:04:51 +00:00
|
|
|
click.echo(f'Instance added to the whitelist: {instance}')
|
|
|
|
|
|
|
|
|
|
|
|
@cli_whitelist.command('remove')
|
|
|
|
@click.argument('instance')
|
|
|
|
def cli_whitelist_remove(instance):
|
|
|
|
'Remove an instance from the whitelist'
|
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
if not app.config.del_whitelist(instance):
|
2022-05-06 07:04:51 +00:00
|
|
|
return click.echo(f'Instance not in the whitelist: {instance}')
|
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
app.config.save()
|
2022-05-06 07:04:51 +00:00
|
|
|
|
2022-11-07 12:54:32 +00:00
|
|
|
if app.config.whitelist_enabled:
|
2022-11-25 18:29:45 +00:00
|
|
|
if app.database.del_inbox(instance):
|
2022-11-07 12:54:32 +00:00
|
|
|
app.database.save()
|
2022-05-06 07:04:51 +00:00
|
|
|
|
|
|
|
click.echo(f'Removed instance from the whitelist: {instance}')
|
|
|
|
|
|
|
|
|
2022-12-04 06:09:45 +00:00
|
|
|
@cli_whitelist.command('import')
|
|
|
|
def cli_whitelist_import():
|
|
|
|
'Add all current inboxes to the whitelist'
|
|
|
|
|
|
|
|
for domain in app.database.hostnames:
|
|
|
|
cli_whitelist_add.callback(domain)
|
|
|
|
|
|
|
|
|
2022-05-06 07:04:51 +00:00
|
|
|
def main():
|
|
|
|
cli(prog_name='relay')
|
2018-11-18 14:41:00 +00:00
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
2022-05-06 07:04:51 +00:00
|
|
|
click.echo('Running relay.manage is depreciated. Run `activityrelay [command]` instead.')
|