diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d45b8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.mypy_cache +/config.json +/venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..503cd4f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,31 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.1.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-docstring-first + - id: check-yaml + - id: debug-statements +- repo: https://gitlab.com/pycqa/flake8 + rev: 3.7.7 + hooks: + - id: flake8 +- repo: https://github.com/pre-commit/mirrors-autopep8 + rev: v1.4.3 + hooks: + - id: autopep8 +- repo: https://github.com/asottile/reorder_python_imports + rev: v1.4.0 + hooks: + - id: reorder-python-imports + args: [--py3-plus] +- repo: https://github.com/asottile/add-trailing-comma + rev: v0.8.0 + hooks: + - id: add-trailing-comma + args: [--py36-plus] +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.670 + hooks: + - id: mypy diff --git a/README.md b/README.md new file mode 100644 index 0000000..2030b88 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +twitch-chat-bot +=============== + +A hackety chat bot I wrote for my twitch stream. I wanted to learn asyncio +and this felt like a decent project to dive in on. + +## setup + +1. Set up a configuration file + + ```json + { + "username": "...", + "channel": "...", + "oauth_token": "...", + "client_id": "..." + } + ``` + + - `username`: the username of the bot account + - `channel`: the irc channel to connect to, for twitch this is the same as + the streamer's channel name + - `oauth_token`: follow the directions [here][docs-irc] to get a token + - `client_id`: set up an application for your chat bot [here][app-setup] + +1. Use python3.7 or newer and install `aiohttp` + + ```bash + virtualenv venv -ppython3.7 + venv/bin/pip install aiohttp + ``` + +[docs-irc]: https://dev.twitch.tv/docs/irc/ +[app-setup]: https://dev.twitch.tv/docs/authentication/#registration + +## implemented commands + +### `!help` + +List all the currently supported commands + +``` +anthonywritescode: !help +anthonywritescodebot: possible commands: !help, !ohai, !uptime +``` + +### `!ohai` + +Greet yo self + +``` +anthonywritescode: !ohai +anthonywritescodebot: ohai, anthonywritescode! +``` + +### `!uptime` + +Show how long the stream has been running for + +``` +anthonywritescode: !uptime +anthonywritescodebot: streaming for: 3 hours, 57 minutes, 17 seconds +``` + +### `PING ...` + +Replies `PONG` to whatever you say + +``` +anthonywritescode: PING +anthonywritescodebot: PONG +anthonywritescode: PING hello +anthonywritescodebot: PONG hello +``` diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..70da445 --- /dev/null +++ b/bot.py @@ -0,0 +1,223 @@ +import argparse +import asyncio +import datetime +import json +import re +import sys +import traceback +from typing import Callable +from typing import List +from typing import Match +from typing import NamedTuple +from typing import NoReturn +from typing import Optional +from typing import Pattern +from typing import Tuple + +import aiohttp + +# TODO: allow host / port to be configurable +HOST = 'irc.chat.twitch.tv' +PORT = 6697 + + +class Config(NamedTuple): + username: str + channel: str + oauth_token: str + client_id: str + client_secret: str + + def __repr__(self) -> str: + return ( + f'{type(self).__name__}(' + f'username={self.username!r}, ' + f'channel={self.channel!r}, ' + f'oauth_token={"***"!r}, ' + f'client_id={"***"!r}, ' + f'client_secret={"***"!r}, ' + f')' + ) + + +def esc(s: str) -> str: + return s.replace('{', '{{').replace('}', '}}') + + +async def send( + writer: asyncio.StreamWriter, + msg: str, + *, + quiet: bool = False, +) -> None: + if not quiet: + print(f'< {msg}', end='', flush=True, file=sys.stderr) + writer.write(msg.encode()) + return await writer.drain() + + +async def recv( + reader: asyncio.StreamReader, + *, + quiet: bool = False, +) -> bytes: + data = await reader.readline() + if not quiet: + sys.stderr.buffer.write(b'> ') + sys.stderr.buffer.write(data) + sys.stderr.flush() + return data + + +class Response: + async def __call__(self, config: Config) -> Optional[str]: + return None + + +class CmdResponse(Response): + def __init__(self, cmd: str) -> None: + self.cmd = cmd + + async def __call__(self, config: Config) -> Optional[str]: + return self.cmd + + +PRIVMSG = 'PRIVMSG #{channel} :{msg}\r\n' + + +class MessageResponse(Response): + def __init__(self, match: Match[str], msg_fmt: str) -> None: + self.match = match + self.msg_fmt = msg_fmt + + async def __call__(self, config: Config) -> Optional[str]: + params = self.match.groupdict() + params['msg'] = self.msg_fmt.format(**params) + return PRIVMSG.format(**params) + + +Callback = Callable[[Match[str]], Response] +HANDLERS: List[Tuple[Pattern[str], Callable[[Match[str]], Response]]] +HANDLERS = [] + + +def handler(prefix: str) -> Callable[[Callback], Callback]: + def handler_decorator(func: Callback) -> Callback: + HANDLERS.append((re.compile(prefix + '\r\n$'), func)) + return func + return handler_decorator + + +def handle_message(message_prefix: str) -> Callable[[Callback], Callback]: + return handler( + f'^:(?P[^!]+).* ' + f'PRIVMSG #(?P[^ ]+) ' + f':(?P{message_prefix}.*)', + ) + + +@handler('^PING (.*)') +def pong(match: Match[str]) -> Response: + return CmdResponse(f'PONG {match.group(1)}\r\n') + + +@handle_message('!ohai') +def cmd_ohai(match: Match[str]) -> Response: + return MessageResponse(match, 'ohai, {user}!') + + +class UptimeResponse(Response): + async def __call__(self, config: Config) -> Optional[str]: + url = f'https://api.twitch.tv/helix/streams?user_login={config.channel}' # noqa: E501 + headers = {'Client-ID': config.client_id} + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: + text = await response.text() + start_time_s = json.loads(text)['data'][0]['started_at'] + start_time = datetime.datetime.strptime( + start_time_s, '%Y-%m-%dT%H:%M:%SZ', + ) + elapsed = (datetime.datetime.utcnow() - start_time).seconds + + parts = [] + for n, unit in ( + (60 * 60, 'hours'), + (60, 'minutes'), + (1, 'seconds'), + ): + if elapsed // n: + parts.append(f'{elapsed // n} {unit}') + elapsed %= n + msg = f'streaming for: {", ".join(parts)}' + return PRIVMSG.format(channel=config.channel, msg=msg) + + +@handle_message('!uptime') +def cmd_uptime(match: Match[str]) -> Response: + return UptimeResponse() + + +@handle_message(r'!\w') +def cmd_help(match: Match[str]) -> Response: + msg = 'possible commands: !help, !ohai, !uptime' + if not match['msg'].startswith('!help'): + msg = f'unknown command ({esc(match["msg"].split()[0])}), {msg}' + return MessageResponse(match, msg) + + +@handle_message('PING') +def msg_ping(match: Match[str]) -> Response: + _, _, msg = match.groups() + _, _, rest = msg.partition(' ') + return MessageResponse(match, f'PONG {esc(rest)}') + + +# TODO: !tags, only allowed by stream admin / mods???? + + +@handler('.*') +def unhandled(match: Match[str]) -> Response: + print(f'UNHANDLED: {match.group()}', end='') + return Response() + + +async def amain(config: Config) -> NoReturn: + reader, writer = await asyncio.open_connection(HOST, PORT, ssl=True) + + await send(writer, f'PASS {config.oauth_token}\r\n', quiet=True) + await send(writer, f'NICK {config.username}\r\n') + await send(writer, f'JOIN #{config.channel}\r\n') + + while True: + data = await recv(reader) + msg = data.decode('UTF-8', errors='backslashreplace') + for pattern, handler in HANDLERS: + match = pattern.match(msg) + if match: + try: + res = await handler(match)(config) + except Exception as e: + traceback.print_exc() + res = PRIVMSG.format( + channel=config.channel, + msg=f'*** unhandled {type(e).__name__} -- see logs', + ) + if res is not None: + await send(writer, res) + break + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument('--config', default='config.json') + args = parser.parse_args() + + with open(args.config) as f: + config = Config(**json.load(f)) + + asyncio.run(amain(config)) + return 0 + + +if __name__ == '__main__': + exit(main()) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..6268802 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,13 @@ +[mypy] +check_untyped_defs = true +disallow_any_generics = true +disallow_incomplete_defs = true +disallow_untyped_defs = true +no_implicit_optional = true +python_version = 3.7 + +[mypy-testing.*] +disallow_untyped_defs = false + +[mypy-tests.*] +disallow_untyped_defs = false diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ee4ba4f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +aiohttp