Add !followage command

This commit is contained in:
Senpos 2020-05-09 12:57:13 +03:00 committed by Anthony Sottile
parent c83cf9e130
commit 070195fd64
4 changed files with 142 additions and 10 deletions

View File

@ -1,31 +1,32 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.4.0 rev: v2.5.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-docstring-first - id: check-docstring-first
- id: check-yaml - id: check-yaml
- id: debug-statements - id: debug-statements
- id: double-quote-string-fixer
- repo: https://gitlab.com/pycqa/flake8 - repo: https://gitlab.com/pycqa/flake8
rev: 3.7.9 rev: 3.8.0a2
hooks: hooks:
- id: flake8 - id: flake8
- repo: https://github.com/pre-commit/mirrors-autopep8 - repo: https://github.com/pre-commit/mirrors-autopep8
rev: v1.4.4 rev: v1.5.2
hooks: hooks:
- id: autopep8 - id: autopep8
- repo: https://github.com/asottile/reorder_python_imports - repo: https://github.com/asottile/reorder_python_imports
rev: v1.8.0 rev: v2.3.0
hooks: hooks:
- id: reorder-python-imports - id: reorder-python-imports
args: [--py3-plus] args: [--py3-plus]
- repo: https://github.com/asottile/add-trailing-comma - repo: https://github.com/asottile/add-trailing-comma
rev: v1.5.0 rev: v2.0.1
hooks: hooks:
- id: add-trailing-comma - id: add-trailing-comma
args: [--py36-plus] args: [--py36-plus]
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.750 rev: v0.770
hooks: hooks:
- id: mypy - id: mypy

View File

@ -85,3 +85,18 @@ Show the discord url
anthonywritescode: !discord anthonywritescode: !discord
anthonywritescodebot: We do have Discord, you are welcome to join: https://discord.gg/HxpQ3px anthonywritescodebot: We do have Discord, you are welcome to join: https://discord.gg/HxpQ3px
``` ```
### `!followage [username]`
Show how long you or a user you specified have been following the channel
```
not_cool_user: !followage
anthonywritescodebot: not_cool_user is not a follower!
cool_user: !followage
anthonywritescodebot: cool_user has been following for 3 hours!
some_user: !followage another_user
anthonywritescodebot: another_user has been following for 5 years!
```

122
bot.py
View File

@ -11,6 +11,7 @@ import struct
import sys import sys
import tempfile import tempfile
import traceback import traceback
from typing import Any
from typing import Callable from typing import Callable
from typing import Dict from typing import Dict
from typing import List from typing import List
@ -23,13 +24,15 @@ from typing import Tuple
import aiohttp import aiohttp
import aiosqlite import aiosqlite
import async_lru
from humanize import naturaldelta
# TODO: allow host / port to be configurable # TODO: allow host / port to be configurable
HOST = 'irc.chat.twitch.tv' HOST = 'irc.chat.twitch.tv'
PORT = 6697 PORT = 6697
MSG_RE = re.compile('^@([^ ]+) :([^!]+).* PRIVMSG #[^ ]+ :([^\r]+)') MSG_RE = re.compile('^@([^ ]+) :([^!]+).* PRIVMSG #[^ ]+ :([^\r]+)')
PRIVMSG = 'PRIVMSG #{channel} :{msg}\r\n' PRIVMSG = 'PRIVMSG #{channel} : {msg}\r\n'
SEND_MSG_RE = re.compile('^PRIVMSG #[^ ]+ :(?P<msg>[^\r]+)') SEND_MSG_RE = re.compile('^PRIVMSG #[^ ]+ :(?P<msg>[^\r]+)')
@ -202,7 +205,7 @@ _TEXT_COMMANDS = (
( (
'!github', '!github',
"anthony's github is https://github.com/asottile -- stream github is " "anthony's github is https://github.com/asottile -- stream github is "
"https://github.com/anthonywritescode", 'https://github.com/anthonywritescode',
), ),
('!homeland', 'WE WILL PROTECT OUR HOMELAND!'), ('!homeland', 'WE WILL PROTECT OUR HOMELAND!'),
( (
@ -230,8 +233,6 @@ for _cmd, _msg in _TEXT_COMMANDS:
@handle_message('!still') @handle_message('!still')
def cmd_still(match: Match[str]) -> Response: def cmd_still(match: Match[str]) -> Response:
_, _, rest = match['msg'].partition(' ') _, _, rest = match['msg'].partition(' ')
if rest.startswith('/disconnect'):
rest = f' {rest}'
year = datetime.date.today().year year = datetime.date.today().year
lol = random.choice(['LOL', 'LOLW', 'LMAO', 'NUUU']) lol = random.choice(['LOL', 'LOLW', 'LMAO', 'NUUU'])
return MessageResponse(match, f'{esc(rest)}, in {year} - {lol}!') return MessageResponse(match, f'{esc(rest)}, in {year} - {lol}!')
@ -387,6 +388,119 @@ def cmd_uptime(match: Match[str]) -> Response:
return UptimeResponse() return UptimeResponse()
@async_lru.alru_cache(maxsize=32)
async def fetch_twitch_user(
user: str,
*,
oauth_token: str,
client_id: str
) -> Optional[List[Dict[str, Any]]]:
url = 'https://api.twitch.tv/helix/users'
params = [('login', user)]
headers = {
'Authorization': f'Bearer {oauth_token}',
'Client-ID': client_id,
}
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, headers=headers) as resp:
json_resp = await resp.json()
return json_resp.get('data')
async def fetch_twitch_user_follows(
*,
from_id: int,
to_id: int,
oauth_token: str,
client_id: str
) -> Optional[List[Dict[str, Any]]]:
url = 'https://api.twitch.tv/helix/users/follows'
params = [('from_id', from_id), ('to_id', to_id)]
headers = {
'Authorization': f'Bearer {oauth_token}',
'Client-ID': client_id,
}
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, headers=headers) as resp:
json_resp = await resp.json()
return json_resp.get('data')
class FollowageResponse(Response):
def __init__(self, username: str) -> None:
self.username = username
async def __call__(self, config: Config) -> Optional[str]:
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(
self.username,
oauth_token=token,
client_id=config.client_id,
)
if not fetched_users:
msg = f'user {esc(self.username)} not found!'
return PRIVMSG.format(channel=config.channel, msg=msg)
target_user, = fetched_users
# if streamer wants to check the followage to their own channel
if me['id'] == target_user['id']:
msg = (
f"@{esc(target_user['login'])}, you can't check !followage "
f'to your own channel. But I appreciate your curiosity!'
)
return PRIVMSG.format(channel=config.channel, msg=msg)
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:
msg = f'{esc(target_user["login"])} is not a follower!'
return PRIVMSG.format(channel=config.channel, msg=msg)
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
msg = (
f'{esc(follow_age["from_name"])} has been following for '
f'{esc(naturaldelta(delta))}!'
)
return PRIVMSG.format(channel=config.channel, msg=msg)
# !followage -> valid, checks the caller
# !followage anthonywritescode -> valid, checks the user passed in payload
# !followage foo bar -> still valid, however the whole
# "foo bar" will be processed as a username
@handle_message(r'!followage(?P<payload> .*)?')
def cmd_followage(match: Match[str]) -> Response:
user = match['user']
# "" is a default value if group is missing
groupdict = match.groupdict('')
payload = groupdict['payload'].strip()
if payload:
user = payload.lstrip('@')
return FollowageResponse(user)
@handle_message(r'!pep[ ]?(?P<pep_num>\d{1,4})') @handle_message(r'!pep[ ]?(?P<pep_num>\d{1,4})')
def cmd_pep(match: Match[str]) -> Response: def cmd_pep(match: Match[str]) -> Response:
n = str(int(match['pep_num'])).zfill(4) n = str(int(match['pep_num'])).zfill(4)

View File

@ -1,2 +1,4 @@
aiohttp aiohttp
aiosqlite aiosqlite
async-lru
humanize