Initial commit
This commit is contained in:
parent
1c0b8c92c6
commit
dcd42e86e0
|
|
@ -0,0 +1,3 @@
|
||||||
|
/.mypy_cache
|
||||||
|
/config.json
|
||||||
|
/venv
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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<user>[^!]+).* '
|
||||||
|
f'PRIVMSG #(?P<channel>[^ ]+) '
|
||||||
|
f':(?P<msg>{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())
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
aiohttp
|
||||||
Loading…
Reference in New Issue