async def cmd_bonkrank(config: Config, match: Match[str]) -> str: user = optional_user_arg(match) ret = _rank(user, BONKER_RE) if ret is None: return format_msg(match, f'user not found {esc(user)}') else: rank, n = ret return format_msg( match, f'{esc(user)} is ranked #{rank}, has bonked others {n} times', )
async def cmd_bonkedrank(config: Config, match: Match[str]) -> str: user = optional_user_arg(match) ret = _user_rank_by_line_type(user, BONKED_RE) if ret is None: return format_msg(match, f'user not found {esc(user)}') else: rank, n = ret return format_msg( match, f'{esc(user)} is ranked #{rank}, has been bonked {n} times', )
async def cmd_chatrank(config: Config, match: Match[str]) -> str: user = optional_user_arg(match) ret = _rank(user, CHAT_LOG_RE) if ret is None: return format_msg(match, f'user not found {esc(user)}') else: rank, n = ret return format_msg( match, f'{esc(user)} is ranked #{rank} with {n} messages ' f'(since {_log_start_date()})', )
async def cmd_followage(config: Config, match: Match[str]) -> str: username = optional_user_arg(match) token = config.oauth_token.split(':')[1] fetched_users = await fetch_twitch_user( config.channel, oauth_token=token, client_id=config.client_id, ) assert fetched_users is not None me, = fetched_users fetched_users = await fetch_twitch_user( username, oauth_token=token, client_id=config.client_id, ) if not fetched_users: return format_msg(match, f'user {esc(username)} not found!') target_user, = fetched_users # if streamer wants to check the followage to their own channel if me['id'] == target_user['id']: return format_msg( match, f"@{esc(target_user['login'])}, you can't check !followage " f'to your own channel. But I appreciate your curiosity!', ) follow_age_results = await fetch_twitch_user_follows( from_id=target_user['id'], to_id=me['id'], oauth_token=token, client_id=config.client_id, ) if not follow_age_results: return format_msg( match, f'{esc(target_user["login"])} is not a follower!', ) follow_age, = follow_age_results now = datetime.datetime.utcnow() date_of_follow = datetime.datetime.fromisoformat( # twitch sends ISO date string with "Z" at the end, # which python's fromisoformat method does not like follow_age['followed_at'].rstrip('Z'), ) delta = now - date_of_follow return format_msg( match, f'{esc(follow_age["from_name"])} has been following for ' f'{esc(humanize.naturaldelta(delta))}!', )
async def cmd_vimbitsrank(config: Config, match: Match[str]) -> str: user = optional_user_arg(match) async with aiosqlite.connect('db.db') as db: ret = await _user_rank_by_bits(user, db) if ret is None: return format_msg(match, f'user not found {esc(user)}') else: rank, n = ret return format_msg( match, f'{esc(user)} is ranked #{rank} with {n} vim bits', )
async def cmd_chatplot(config: Config, match: Match[str]) -> str: user = optional_user_arg(match).lower() min_date = datetime.date.fromisoformat(_log_start_date()) x: list[int] = [] y = [] for filename in sorted(os.listdir('logs')): if filename == f'{datetime.date.today()}.log': continue filename_date = datetime.date.fromisoformat(filename.split('.')[0]) full_filename = os.path.join('logs', filename) counts = _counts_per_file(full_filename, CHAT_LOG_RE) if x or counts[user]: x.append((filename_date - min_date).days) y.append(counts[user]) if len(x) < 2: return format_msg( match, f'sorry {esc(user)}, need at least 2 days of data', ) m, c = lin_regr(x, y) chart = { 'type': 'scatter', 'data': { 'datasets': [ { 'label': 'chats', 'data': [ {'x': x_i, 'y': y_i} for x_i, y_i in zip(x, y) if y_i ], }, { 'label': 'trend', 'type': 'line', 'fill': False, 'pointRadius': 0, 'data': [ {'x': x[0], 'y': m * x[0] + c}, {'x': x[-1], 'y': m * x[-1] + c}, ], }, ], }, 'options': { 'scales': { 'xAxes': [{'ticks': {'callback': 'CALLBACK'}}], 'yAxes': [{'ticks': {'beginAtZero': True, 'min': 0}}], }, 'title': { 'display': True, 'text': f"{user}'s chat in twitch.tv/{config.channel}", }, }, } callback = ( 'x=>{' f'y=new Date({str(min_date)!r});' 'y.setDate(x+y.getDate());return y.toISOString().slice(0,10)' '}' ) data = json.dumps(chart, separators=(',', ':')) data = data.replace('"CALLBACK"', callback) post_data = {'chart': data} request = urllib.request.Request( 'https://quickchart.io/chart/create', method='POST', data=json.dumps(post_data).encode(), headers={'Content-Type': 'application/json'}, ) resp = urllib.request.urlopen(request) contents = json.load(resp) return format_msg(match, f'{esc(user)}: {contents["url"]}')
async def cmd_followage(config: Config, match: Match[str]) -> str: username = optional_user_arg(match) me = await fetch_twitch_user( config.channel, oauth_token=config.oauth_token_token, client_id=config.client_id, ) assert me is not None target_user = await fetch_twitch_user( username, oauth_token=config.oauth_token_token, client_id=config.client_id, ) if target_user is None: return format_msg(match, f'user {esc(username)} not found!') # if streamer wants to check the followage to their own channel if me['id'] == target_user['id']: return format_msg( match, f"@{esc(target_user['login'])}, you can't check !followage " f'to your own channel. But I appreciate your curiosity!', ) follow_age_results = await fetch_twitch_user_follows( from_id=target_user['id'], to_id=me['id'], oauth_token=config.oauth_token_token, client_id=config.client_id, ) if not follow_age_results: return format_msg( match, f'{esc(target_user["login"])} is not a follower!', ) follow_age, = follow_age_results now = datetime.datetime.utcnow() date_of_follow = datetime.datetime.fromisoformat( # twitch sends ISO date string with "Z" at the end, # which python's fromisoformat method does not like follow_age['followed_at'].rstrip('Z'), ) delta = now - date_of_follow if delta <= datetime.timedelta(days=2): # using 2 days because precisedelta returns "1 days" humanize_string = humanize.naturaldelta(delta) else: humanize_string = humanize.precisedelta( delta, minimum_unit='days', format='%d', ) # odd month outputs are broken because mod 30.5 humanize_string = humanize_string.replace(' 1 days', ' 1 day') humanize_string = humanize_string.replace(' and 0 days', '') return format_msg( match, f'{esc(follow_age["from_name"])} has been following for ' f'{esc(humanize_string)}!', )
async def cmd_chatplot(config: Config, match: Match[str]) -> str: user_list = optional_user_arg(match).lower().split() user_list = [_alias(user.lstrip('@')) for user in user_list] user_list = list(dict.fromkeys(user_list)) if len(user_list) > 2: return format_msg(match, 'sorry, can only compare 2 users') min_date = datetime.date.fromisoformat(_log_start_date()) comp_users: dict[str, dict[str, list[int]]] comp_users = collections.defaultdict(lambda: {'x': [], 'y': []}) for filename in sorted(os.listdir('logs')): if filename == f'{datetime.date.today()}.log': continue filename_date = datetime.date.fromisoformat(filename.split('.')[0]) full_filename = os.path.join('logs', filename) counts = _counts_per_file(full_filename, CHAT_LOG_RE) for user in user_list: if counts[user]: comp_users[user]['x'].append((filename_date - min_date).days) comp_users[user]['y'].append(counts[user]) # create the datasets (scatter and trend line) for all users to compare PLOT_COLORS = ('#00a3ce', '#fab040') datasets: list[dict[str, Any]] = [] for user, color in zip(user_list, PLOT_COLORS): if len(comp_users[user]['x']) < 2: if len(user_list) > 1: return format_msg( match, 'sorry, all users need at least 2 days of data', ) else: return format_msg( match, f'sorry {esc(user)}, need at least 2 days of data', ) point_data = { 'label': f"{user}'s chats", 'borderColor': color, # add alpha to the point fill color 'backgroundColor': f'{color}69', 'data': [ {'x': x_i, 'y': y_i} for x_i, y_i in zip(comp_users[user]['x'], comp_users[user]['y']) if y_i ], } m, c = lin_regr(comp_users[user]['x'], comp_users[user]['y']) trend_data = { 'borderColor': color, 'type': 'line', 'fill': False, 'pointRadius': 0, 'data': [ { 'x': comp_users[user]['x'][0], 'y': m * comp_users[user]['x'][0] + c, }, { 'x': comp_users[user]['x'][-1], 'y': m * comp_users[user]['x'][-1] + c, }, ], } datasets.append(point_data) datasets.append(trend_data) # generate title checking if we are comparing users if len(user_list) > 1: title_user = "******".join(user_list) title_user = f"{title_user}'s" else: title_user = f"{user_list[0]}'s" chart = { 'type': 'scatter', 'data': { 'datasets': datasets, }, 'options': { 'scales': { 'xAxes': [{'ticks': {'callback': 'CALLBACK'}}], 'yAxes': [{'ticks': {'beginAtZero': True, 'min': 0}}], }, 'title': { 'display': True, 'text': f'{title_user} chat in twitch.tv/{config.channel}', }, 'legend': { 'labels': {'filter': 'FILTER'}, }, }, } callback = ( 'x=>{' f'y=new Date({str(min_date)!r});' 'y.setDate(x+y.getDate());return y.toISOString().slice(0,10)' '}' ) # https://github.com/chartjs/Chart.js/issues/3189#issuecomment-528362213 filter = ( '(legendItem, chartData)=>{' ' return (chartData.datasets[legendItem.datasetIndex].label);' '}' ) data = json.dumps(chart, separators=(',', ':')) data = data.replace('"CALLBACK"', callback) data = data.replace('"FILTER"', filter) post_data = {'chart': data} request = urllib.request.Request( 'https://quickchart.io/chart/create', method='POST', data=json.dumps(post_data).encode(), headers={'Content-Type': 'application/json'}, ) resp = urllib.request.urlopen(request) contents = json.load(resp) user_esc = [esc(user) for user in user_list] if len(user_list) > 1: return format_msg( match, f'comparing {", ".join(user_esc)}: {contents["url"]}', ) else: return format_msg(match, f'{esc(user_esc[0])}: {contents["url"]}')