2018-12-10 20:09:35 +01:00
from datetime import datetime , timedelta
2019-02-28 19:36:31 +01:00
import aiohttp
2018-08-02 11:10:09 +02:00
import dateparser
2018-07-24 22:47:57 +02:00
import humanize
2019-03-08 16:07:05 +01:00
import math
2019-02-28 19:36:31 +01:00
import timezonefinder
2018-12-18 19:38:53 +01:00
import pytz
2018-07-24 22:47:57 +02:00
2018-09-16 19:36:50 +02:00
import pluralkit . bot . embeds
2018-07-24 22:47:57 +02:00
from pluralkit . bot . commands import *
2018-12-05 11:44:10 +01:00
from pluralkit . errors import ExistingSystemError , UnlinkingLastAccountError , AccountAlreadyLinkedError
from pluralkit . utils import display_relative
2019-02-28 19:36:31 +01:00
# This needs to load from the timezone file so we're preloading this so we
# don't have to do it on every invocation
tzf = timezonefinder . TimezoneFinder ( )
2018-12-05 11:44:10 +01:00
async def system_root ( ctx : CommandContext ) :
# Commands that operate without a specified system (usually defaults to the executor's own system)
if ctx . match ( " name " ) or ctx . match ( " rename " ) :
await system_name ( ctx )
elif ctx . match ( " description " ) :
await system_description ( ctx )
elif ctx . match ( " avatar " ) or ctx . match ( " icon " ) :
await system_avatar ( ctx )
elif ctx . match ( " tag " ) :
await system_tag ( ctx )
elif ctx . match ( " new " ) or ctx . match ( " register " ) or ctx . match ( " create " ) or ctx . match ( " init " ) :
await system_new ( ctx )
elif ctx . match ( " delete " ) or ctx . match ( " delete " ) or ctx . match ( " erase " ) :
await system_delete ( ctx )
elif ctx . match ( " front " ) or ctx . match ( " fronter " ) or ctx . match ( " fronters " ) :
await system_fronter ( ctx , await ctx . ensure_system ( ) )
elif ctx . match ( " fronthistory " ) :
await system_fronthistory ( ctx , await ctx . ensure_system ( ) )
elif ctx . match ( " frontpercent " ) or ctx . match ( " frontbreakdown " ) or ctx . match ( " frontpercentage " ) :
await system_frontpercent ( ctx , await ctx . ensure_system ( ) )
2018-12-18 19:38:53 +01:00
elif ctx . match ( " timezone " ) or ctx . match ( " tz " ) :
await system_timezone ( ctx )
2018-12-05 11:44:10 +01:00
elif ctx . match ( " set " ) :
await system_set ( ctx )
2019-03-08 14:48:33 +01:00
elif ctx . match ( " list " ) or ctx . match ( " members " ) :
await system_list ( ctx , await ctx . ensure_system ( ) )
2018-12-05 11:44:10 +01:00
elif not ctx . has_next ( ) :
# (no argument, command ends here, default to showing own system)
await system_info ( ctx , await ctx . ensure_system ( ) )
else :
# If nothing matches, the next argument is likely a system name/ID, so delegate
# to the specific system root
await specified_system_root ( ctx )
async def specified_system_root ( ctx : CommandContext ) :
# Commands that operate on a specified system (ie. not necessarily the command executor's)
system_name = ctx . pop_str ( )
system = await utils . get_system_fuzzy ( ctx . conn , ctx . client , system_name )
if not system :
raise CommandError (
2018-12-10 20:09:35 +01:00
" Unable to find system ` {} `. If you meant to run a command, type `pk;help system` for a list of system commands. " . format (
2018-12-05 11:44:10 +01:00
system_name ) )
if ctx . match ( " front " ) or ctx . match ( " fronter " ) :
await system_fronter ( ctx , system )
elif ctx . match ( " fronthistory " ) :
await system_fronthistory ( ctx , system )
elif ctx . match ( " frontpercent " ) or ctx . match ( " frontbreakdown " ) or ctx . match ( " frontpercentage " ) :
await system_frontpercent ( ctx , system )
2019-03-08 14:48:33 +01:00
elif ctx . match ( " list " ) or ctx . match ( " members " ) :
await system_list ( ctx , system )
2018-09-07 17:34:38 +02:00
else :
2018-12-05 11:44:10 +01:00
await system_info ( ctx , system )
2018-08-02 11:10:09 +02:00
2018-12-05 11:44:10 +01:00
async def system_info ( ctx : CommandContext , system : System ) :
2019-03-08 14:48:33 +01:00
this_system = await ctx . get_system ( )
await ctx . reply ( embed = await pluralkit . bot . embeds . system_card ( ctx . conn , ctx . client , system , this_system and this_system . id == system . id ) )
2018-07-24 22:47:57 +02:00
2018-12-05 11:44:10 +01:00
async def system_new ( ctx : CommandContext ) :
new_name = ctx . remaining ( ) or None
2018-07-24 22:47:57 +02:00
2018-09-09 20:38:57 +02:00
try :
2018-12-05 11:44:10 +01:00
await System . create_system ( ctx . conn , ctx . message . author . id , new_name )
2018-09-09 20:50:53 +02:00
except ExistingSystemError as e :
2018-11-15 21:05:13 +01:00
raise CommandError ( e . message )
2018-07-24 22:47:57 +02:00
2018-11-15 21:05:13 +01:00
await ctx . reply_ok ( " System registered! To begin adding members, use `pk;member new <name>`. " )
2018-09-07 17:34:38 +02:00
2018-07-24 22:47:57 +02:00
2018-09-07 17:34:38 +02:00
async def system_set ( ctx : CommandContext ) :
2018-11-30 21:42:01 +01:00
raise CommandError (
2018-12-11 20:04:26 +01:00
" `pk;system set` has been retired. Please use the new system modifying commands. Type `pk;help system` for a list. " )
2018-11-23 21:55:47 +01:00
async def system_name ( ctx : CommandContext ) :
2018-09-07 17:34:38 +02:00
system = await ctx . ensure_system ( )
2018-11-23 21:55:47 +01:00
new_name = ctx . remaining ( ) or None
2018-09-07 17:34:38 +02:00
2018-11-23 21:55:47 +01:00
await system . set_name ( ctx . conn , new_name )
await ctx . reply_ok ( " System name {} . " . format ( " updated " if new_name else " cleared " ) )
2018-07-24 22:47:57 +02:00
2018-09-16 13:46:22 +02:00
2018-11-23 21:55:47 +01:00
async def system_description ( ctx : CommandContext ) :
system = await ctx . ensure_system ( )
new_description = ctx . remaining ( ) or None
2018-09-09 20:38:57 +02:00
2018-11-23 21:55:47 +01:00
await system . set_description ( ctx . conn , new_description )
await ctx . reply_ok ( " System description {} . " . format ( " updated " if new_description else " cleared " ) )
2018-07-24 22:47:57 +02:00
2018-12-18 19:38:53 +01:00
async def system_timezone ( ctx : CommandContext ) :
system = await ctx . ensure_system ( )
2019-02-28 19:36:31 +01:00
city_query = ctx . remaining ( ) or None
msg = await ctx . reply ( " \U0001F50D Searching ' {} ' (may take a while)... " . format ( city_query ) )
# Look up the city on Overpass (OpenStreetMap)
async with aiohttp . ClientSession ( ) as sess :
# OverpassQL is weird, but this basically searches for every node of type city with name [input].
async with sess . get ( " https://nominatim.openstreetmap.org/search?city=novosibirsk&format=json&limit=1 " , params = { " city " : city_query , " format " : " json " , " limit " : " 1 " } ) as r :
if r . status != 200 :
raise CommandError ( " OSM Nominatim API returned error. Try again. " )
data = await r . json ( )
# If we didn't find a city, complain
if not data :
raise CommandError ( " City ' {} ' not found. " . format ( city_query ) )
# Take the lat/long given by Overpass and put it into timezonefinder
lat , lng = ( float ( data [ 0 ] [ " lat " ] ) , float ( data [ 0 ] [ " lon " ] ) )
timezone_name = tzf . timezone_at ( lng = lng , lat = lat )
2019-03-08 14:48:33 +01:00
# Also delete the original searching message
await msg . delete ( )
2019-02-28 19:36:31 +01:00
if not timezone_name :
raise CommandError ( " Time zone for city ' {} ' not found. This should never happen. " . format ( data [ 0 ] [ " display_name " ] ) )
# This should hopefully result in a valid time zone name
# (if not, something went wrong)
tz = await system . set_time_zone ( ctx . conn , timezone_name )
2018-12-18 19:38:53 +01:00
offset = tz . utcoffset ( datetime . utcnow ( ) )
offset_str = " UTC {:+02d} : {:02d} " . format ( int ( offset . total_seconds ( ) / / 3600 ) , int ( offset . total_seconds ( ) / / 60 % 60 ) )
2019-03-08 14:48:33 +01:00
2019-02-28 19:36:31 +01:00
await ctx . reply_ok ( " System time zone set to {} ( {} , {} ). \n *Data from OpenStreetMap, queried using Nominatim.* " . format ( tz . tzname ( datetime . utcnow ( ) ) , offset_str , tz . zone ) )
2018-12-18 19:38:53 +01:00
2018-11-23 21:55:47 +01:00
async def system_tag ( ctx : CommandContext ) :
system = await ctx . ensure_system ( )
new_tag = ctx . remaining ( ) or None
2018-09-09 20:38:57 +02:00
2018-11-23 21:55:47 +01:00
await system . set_tag ( ctx . conn , new_tag )
await ctx . reply_ok ( " System tag {} . " . format ( " updated " if new_tag else " cleared " ) )
2018-09-09 20:38:57 +02:00
2018-11-30 21:42:01 +01:00
# System class is immutable, update the tag so get_member_name_limit works
system = system . _replace ( tag = new_tag )
members = await system . get_members ( ctx . conn )
# Certain members might not be able to be proxied with this new tag, show a warning for those
members_exceeding = [ member for member in members if
len ( member . name ) > system . get_member_name_limit ( ) ]
if members_exceeding :
member_names = " , " . join ( [ member . name for member in members_exceeding ] )
await ctx . reply_warn (
" Due to the length of this tag, the following members will not be able to be proxied: {} . Please use a shorter tag to prevent this. " . format (
member_names ) )
# Edge case: members with name length 1 and no new tag
if not new_tag :
one_length_members = [ member for member in members if len ( member . name ) == 1 ]
if one_length_members :
member_names = " , " . join ( [ member . name for member in one_length_members ] )
await ctx . reply_warn (
" Without a system tag, you will not be able to proxy members with a one-character name: {} . To prevent this, please add a system tag or lengthen their name. " . format (
member_names ) )
2018-11-23 21:55:47 +01:00
async def system_avatar ( ctx : CommandContext ) :
system = await ctx . ensure_system ( )
new_avatar_url = ctx . remaining ( ) or None
if new_avatar_url :
user = await utils . parse_mention ( ctx . client , new_avatar_url )
if user :
new_avatar_url = user . avatar_url_as ( format = " png " )
await system . set_avatar ( ctx . conn , new_avatar_url )
await ctx . reply_ok ( " System avatar {} . " . format ( " updated " if new_avatar_url else " cleared " ) )
2018-07-24 22:47:57 +02:00
2018-09-07 17:34:38 +02:00
2018-12-05 11:44:10 +01:00
async def account_link ( ctx : CommandContext ) :
2018-09-07 17:34:38 +02:00
system = await ctx . ensure_system ( )
2018-12-05 11:44:10 +01:00
account_name = ctx . pop_str ( CommandError (
" You must pass an account to link this system to. You can either use a \\ @mention, or a raw account ID. " ) )
2018-07-24 22:47:57 +02:00
2018-11-23 21:55:47 +01:00
# Do the sanity checking here too (despite it being done in System.link_account)
# Because we want it to be done before the confirmation dialog is shown
2018-07-24 22:47:57 +02:00
# Find account to link
2018-09-07 17:34:38 +02:00
linkee = await utils . parse_mention ( ctx . client , account_name )
2018-07-24 22:47:57 +02:00
if not linkee :
2018-12-05 11:44:10 +01:00
raise CommandError ( " Account ` {} ` not found. " . format ( account_name ) )
2018-07-24 22:47:57 +02:00
# Make sure account doesn't already have a system
2018-09-09 20:38:57 +02:00
account_system = await System . get_by_account ( ctx . conn , linkee . id )
2018-07-24 22:47:57 +02:00
if account_system :
2018-11-15 21:05:13 +01:00
raise CommandError ( AccountAlreadyLinkedError ( account_system ) . message )
2018-07-24 22:47:57 +02:00
2018-12-05 11:44:10 +01:00
msg = await ctx . reply (
" {} , please confirm the link by clicking the \u2705 reaction on this message. " . format ( linkee . mention ) )
2018-11-30 21:51:57 +01:00
if not await ctx . confirm_react ( linkee , msg ) :
2018-11-15 21:05:13 +01:00
raise CommandError ( " Account link cancelled. " )
2018-07-24 22:47:57 +02:00
2018-09-09 20:38:57 +02:00
await system . link_account ( ctx . conn , linkee . id )
2018-11-15 21:05:13 +01:00
await ctx . reply_ok ( " Account linked to system. " )
2018-09-07 17:34:38 +02:00
2018-12-05 11:44:10 +01:00
async def account_unlink ( ctx : CommandContext ) :
2018-09-07 17:34:38 +02:00
system = await ctx . ensure_system ( )
2019-01-17 13:15:47 +00:00
msg = await ctx . reply ( " Are you sure you want to unlink this account from your system? " )
if not await ctx . confirm_react ( ctx . message . author , msg ) :
raise CommandError ( " Account unlink cancelled. " )
2018-07-24 22:47:57 +02:00
2018-09-09 20:38:57 +02:00
try :
await system . unlink_account ( ctx . conn , ctx . message . author . id )
2018-09-09 20:50:53 +02:00
except UnlinkingLastAccountError as e :
2018-11-15 21:05:13 +01:00
raise CommandError ( e . message )
2018-07-24 22:47:57 +02:00
2018-11-15 21:05:13 +01:00
await ctx . reply_ok ( " Account unlinked. " )
2018-07-24 22:47:57 +02:00
2018-08-02 11:10:09 +02:00
2018-12-05 11:44:10 +01:00
async def system_fronter ( ctx : CommandContext , system : System ) :
2018-12-18 20:11:04 +01:00
embed = await embeds . front_status ( ctx , await system . get_latest_switch ( ctx . conn ) )
2018-09-07 17:34:38 +02:00
await ctx . reply ( embed = embed )
2018-08-02 11:10:09 +02:00
2018-12-05 11:44:10 +01:00
async def system_fronthistory ( ctx : CommandContext , system : System ) :
2018-07-24 22:47:57 +02:00
lines = [ ]
2018-08-02 00:36:50 +02:00
front_history = await pluralkit . utils . get_front_history ( ctx . conn , system . id , count = 10 )
2018-12-05 11:44:10 +01:00
if not front_history :
raise CommandError ( " You have no logged switches. Use `pk;switch´ to start logging. " )
2018-07-24 22:47:57 +02:00
for i , ( timestamp , members ) in enumerate ( front_history ) :
# Special case when no one's fronting
if len ( members ) == 0 :
name = " (no fronter) "
else :
name = " , " . join ( [ member . name for member in members ] )
# Make proper date string
2018-12-18 19:38:53 +01:00
time_text = ctx . format_time ( timestamp )
2018-12-05 11:44:10 +01:00
rel_text = display_relative ( timestamp )
2018-07-24 22:47:57 +02:00
delta_text = " "
if i > 0 :
2018-09-07 17:34:38 +02:00
last_switch_time = front_history [ i - 1 ] [ 0 ]
2018-12-05 11:44:10 +01:00
delta_text = " , for {} " . format ( display_relative ( timestamp - last_switch_time ) )
2018-12-18 19:38:53 +01:00
lines . append ( " ** {} ** ( {} , {} ago {} ) " . format ( name , time_text , rel_text , delta_text ) )
2018-07-24 22:47:57 +02:00
2018-09-07 17:34:38 +02:00
embed = embeds . status ( " \n " . join ( lines ) or " (none) " )
2018-07-24 22:47:57 +02:00
embed . title = " Past switches "
2018-09-07 17:34:38 +02:00
await ctx . reply ( embed = embed )
2018-07-24 22:47:57 +02:00
2018-09-07 17:34:38 +02:00
async def system_delete ( ctx : CommandContext ) :
system = await ctx . ensure_system ( )
2018-09-09 20:38:57 +02:00
delete_confirm_msg = " Are you sure you want to delete your system? If so, reply to this message with the system ' s ID (` {} `). " . format (
system . hid )
2018-09-08 13:48:18 +02:00
if not await ctx . confirm_text ( ctx . message . author , ctx . message . channel , system . hid , delete_confirm_msg ) :
2018-11-15 21:05:13 +01:00
raise CommandError ( " System deletion cancelled. " )
2018-09-07 17:34:38 +02:00
2018-09-09 20:38:57 +02:00
await system . delete ( ctx . conn )
2018-11-15 21:05:13 +01:00
await ctx . reply_ok ( " System deleted. " )
2018-09-08 13:48:18 +02:00
2018-08-02 11:10:09 +02:00
2018-12-05 11:44:10 +01:00
async def system_frontpercent ( ctx : CommandContext , system : System ) :
2018-08-02 11:10:09 +02:00
# Parse the time limit (will go this far back)
2018-09-16 19:20:08 +02:00
if ctx . remaining ( ) :
before = dateparser . parse ( ctx . remaining ( ) , languages = [ " en " ] , settings = {
" TO_TIMEZONE " : " UTC " ,
" RETURN_AS_TIMEZONE_AWARE " : False
} )
if not before :
2018-11-15 21:05:13 +01:00
raise CommandError ( " Could not parse ' {} ' as a valid time. " . format ( ctx . remaining ( ) ) )
2018-09-16 19:20:08 +02:00
# If time is in the future, just kinda discard
if before and before > datetime . utcnow ( ) :
before = None
else :
before = datetime . utcnow ( ) - timedelta ( days = 30 )
2018-08-02 11:10:09 +02:00
# Fetch list of switches
2018-09-07 17:34:38 +02:00
all_switches = await pluralkit . utils . get_front_history ( ctx . conn , system . id , 99999 )
2018-08-02 11:10:09 +02:00
if not all_switches :
2018-11-15 21:05:13 +01:00
raise CommandError ( " No switches registered to this system. " )
2018-08-02 11:10:09 +02:00
# Cull the switches *ending* before the limit, if given
# We'll need to find the first switch starting before the limit, then cut off every switch *before* that
if before :
for last_stamp , _ in all_switches :
if last_stamp < before :
break
all_switches = [ ( stamp , members ) for stamp , members in all_switches if stamp > = last_stamp ]
start_times = [ stamp for stamp , _ in all_switches ]
end_times = [ datetime . utcnow ( ) ] + start_times
switch_members = [ members for _ , members in all_switches ]
# Gonna save a list of members by ID for future lookup too
members_by_id = { }
# Using the ID as a key here because it's a simple number that can be hashed and used as a key
member_times = { }
for start_time , end_time , members in zip ( start_times , end_times , switch_members ) :
# Cut off parts of the switch that occurs before the time limit (will only happen if this is the last switch)
if before and start_time < before :
start_time = before
# Calculate length of the switch
switch_length = end_time - start_time
2018-09-07 17:34:38 +02:00
def add_switch ( id , length ) :
if id not in member_times :
member_times [ id ] = length
2018-08-02 11:10:09 +02:00
else :
2018-09-07 17:34:38 +02:00
member_times [ id ] + = length
2018-08-02 11:10:09 +02:00
for member in members :
# Add the switch length to the currently registered time for that member
add_switch ( member . id , switch_length )
# Also save the member in the ID map for future reference
members_by_id [ member . id ] = member
# Also register a no-fronter switch with the key None
if not members :
add_switch ( None , switch_length )
# Find the total timespan of the range
span_start = max ( start_times [ - 1 ] , before ) if before else start_times [ - 1 ]
total_time = datetime . utcnow ( ) - span_start
2018-09-01 19:41:35 +02:00
embed = embeds . status ( " " )
2018-08-02 11:10:09 +02:00
for member_id , front_time in sorted ( member_times . items ( ) , key = lambda x : x [ 1 ] , reverse = True ) :
member = members_by_id [ member_id ] if member_id else None
# Calculate percent
fraction = front_time / total_time
2018-09-16 19:20:08 +02:00
percent = round ( fraction * 100 )
2018-08-02 11:10:09 +02:00
embed . add_field ( name = member . name if member else " (no fronter) " ,
value = " {} % ( {} ) " . format ( percent , humanize . naturaldelta ( front_time ) ) )
2018-12-18 19:38:53 +01:00
embed . set_footer ( text = " Since {} ( {} ago) " . format ( ctx . format_time ( span_start ) ,
2018-12-05 11:44:10 +01:00
display_relative ( span_start ) ) )
2018-09-07 17:34:38 +02:00
await ctx . reply ( embed = embed )
2019-03-08 14:48:33 +01:00
async def system_list ( ctx : CommandContext , system : System ) :
all_members = sorted ( await system . get_members ( ctx . conn ) , key = lambda m : m . name )
page_size = 10
if len ( all_members ) < = page_size :
# If we have less than 10 members, don't bother paginating
await ctx . reply ( embed = embeds . member_list ( await ctx . get_system ( ) , all_members , 0 , page_size = page_size ) )
else :
current_page = 0
msg : discord . Message = None
while True :
2019-03-08 16:07:05 +01:00
page_count = math . ceil ( len ( all_members ) / page_size )
2019-03-08 14:48:33 +01:00
embed = embeds . member_list ( await ctx . get_system ( ) , all_members , current_page )
2019-03-08 16:07:05 +01:00
2019-03-08 14:48:33 +01:00
# Add reactions for moving back and forth
if not msg :
msg = await ctx . reply ( embed = embed )
await msg . add_reaction ( " \u2B05 " )
await msg . add_reaction ( " \u27A1 " )
else :
await msg . edit ( embed = embed )
def check ( reaction , user ) :
return user . id == ctx . message . author . id and reaction . emoji in [ " \u2B05 " , " \u27A1 " ]
try :
reaction , _ = await ctx . client . wait_for ( " reaction_add " , timeout = 5 * 60 , check = check )
except asyncio . TimeoutError :
return
if reaction . emoji == " \u2B05 " :
current_page = ( current_page - 1 ) % page_count
elif reaction . emoji == " \u27A1 " :
current_page = ( current_page + 1 ) % page_count
# If we can, remove the original reaction from the member
if ctx . message . channel . permissions_for ( ctx . message . guild . get_member ( ctx . client . user . id ) ) . manage_messages :
await reaction . remove ( ctx . message . author )