├── bot ├── __init__.py ├── plugins │ ├── __init__.py │ ├── ignored.py │ ├── joke.py │ ├── ping.py │ ├── think.py │ ├── bonk.py │ ├── still.py │ ├── bongo.py │ ├── kevin.py │ ├── so.py │ ├── pep.py │ ├── gnu.py │ ├── uptime.py │ ├── smarts.py │ ├── wideoidea.py │ ├── aqi.py │ ├── today.py │ ├── pronouns.py │ ├── motd.py │ ├── weather.py │ ├── giveaway.py │ ├── youtube_playlist_search.py │ ├── simple.py │ ├── babi_theme.py │ ├── chatrank.py │ └── vim_timer.py ├── __main__.py ├── ranking.py ├── emote.py ├── config.py ├── twitch_api.py ├── image_cache.py ├── util.py ├── cheer.py ├── message.py ├── parse_message.py ├── badges.py ├── data.py └── main.py ├── tests ├── __init__.py ├── plugins │ ├── __init__.py │ ├── simple_test.py │ ├── babi_theme_test.py │ └── chatrank_test.py └── emote_test.py ├── .activate.sh ├── .deactivate.sh ├── requirements-dev.txt ├── .gitignore ├── requirements.txt ├── testing └── stubs │ └── async_lru.pyi ├── .github └── workflows │ └── main.yml ├── tox.ini ├── setup.cfg ├── LICENSE ├── .pre-commit-config.yaml └── README.md /bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bot/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.activate.sh: -------------------------------------------------------------------------------- 1 | venv/bin/activate -------------------------------------------------------------------------------- /.deactivate.sh: -------------------------------------------------------------------------------- 1 | deactivate 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | covdefaults 2 | coverage 3 | pytest 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /.coverage 3 | /.tox 4 | /config.json 5 | /db.db 6 | /logs 7 | -------------------------------------------------------------------------------- /bot/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from bot.main import main 4 | 5 | if __name__ == '__main__': 6 | raise SystemExit(main()) 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | aiosqlite 3 | async-lru 4 | cson 5 | # can remove once we're on expat 2.4.1 (ubuntu jammy) 6 | defusedxml 7 | humanize 8 | pyjokes 9 | -------------------------------------------------------------------------------- /testing/stubs/async_lru.pyi: -------------------------------------------------------------------------------- 1 | from typing import Callable, TypeVar 2 | 3 | T = TypeVar('T') 4 | 5 | def alru_cache(maxsize: int | None) -> Callable[[T], T]: ... 6 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: [main, test-me-*] 6 | tags: '*' 7 | pull_request: 8 | 9 | jobs: 10 | main: 11 | uses: asottile/workflows/.github/workflows/tox.yml@v1.8.1 12 | with: 13 | env: '["py312"]' 14 | -------------------------------------------------------------------------------- /bot/plugins/ignored.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from bot.config import Config 4 | from bot.data import command 5 | from bot.message import Message 6 | 7 | 8 | @command('!ftlwiki', secret=True) 9 | async def cmd_still(config: Config, msg: Message) -> None: 10 | pass 11 | -------------------------------------------------------------------------------- /tests/plugins/simple_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from bot.plugins.simple import _SECRET_COMMANDS 4 | from bot.plugins.simple import _TEXT_COMMANDS 5 | 6 | 7 | def test_secret_commands_are_sorted(): 8 | assert _SECRET_COMMANDS == tuple(sorted(_SECRET_COMMANDS)) 9 | 10 | 11 | def test_text_commands_are_sorted(): 12 | assert _TEXT_COMMANDS == tuple(sorted(_TEXT_COMMANDS)) 13 | -------------------------------------------------------------------------------- /bot/plugins/joke.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pyjokes 4 | 5 | from bot.config import Config 6 | from bot.data import command 7 | from bot.data import esc 8 | from bot.data import format_msg 9 | from bot.message import Message 10 | 11 | 12 | @command('!joke', '!yoke') 13 | async def cmd_joke(config: Config, msg: Message) -> str: 14 | return format_msg(msg, esc(pyjokes.get_joke())) 15 | -------------------------------------------------------------------------------- /bot/plugins/ping.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from bot.config import Config 4 | from bot.data import esc 5 | from bot.data import format_msg 6 | from bot.data import handle_message 7 | from bot.message import Message 8 | 9 | 10 | @handle_message('PING') 11 | async def msg_ping(config: Config, msg: Message) -> str: 12 | _, _, rest = msg.msg.partition(' ') 13 | return format_msg(msg, f'PONG {esc(rest)}') 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py,pre-commit 3 | skipsdist = true 4 | 5 | [testenv] 6 | deps = 7 | -rrequirements.txt 8 | -rrequirements-dev.txt 9 | commands = 10 | coverage erase 11 | coverage run -m pytest {posargs:tests} 12 | coverage report 13 | 14 | [testenv:pre-commit] 15 | skip_install = true 16 | deps = pre-commit 17 | commands = pre-commit run --all-files --show-diff-on-failure 18 | 19 | [pep8] 20 | ignore = E265,E501,W504 21 | -------------------------------------------------------------------------------- /bot/ranking.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | from collections.abc import Iterator 5 | from collections.abc import Sequence 6 | 7 | 8 | def tied_rank( 9 | counts: Sequence[tuple[str, int]], 10 | ) -> Iterator[tuple[int, tuple[int, Iterator[tuple[str, int]]]]]: 11 | # "counts" should be sorted, usually produced by Counter.most_common() 12 | grouped = itertools.groupby(counts, key=lambda pair: pair[1]) 13 | yield from enumerate(grouped, start=1) 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | plugins = covdefaults 3 | 4 | [coverage:report] 5 | # TODO: increase coverage 6 | fail_under = 1 7 | 8 | [mypy] 9 | mypy_path = testing/stubs 10 | 11 | check_untyped_defs = true 12 | disallow_any_generics = true 13 | disallow_incomplete_defs = true 14 | disallow_untyped_defs = true 15 | warn_redundant_casts = true 16 | warn_unused_ignores = true 17 | 18 | [mypy-testing.*] 19 | disallow_untyped_defs = false 20 | 21 | [mypy-tests.*] 22 | disallow_untyped_defs = false 23 | -------------------------------------------------------------------------------- /bot/plugins/think.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import random 4 | import re 5 | 6 | from bot.config import Config 7 | from bot.data import format_msg 8 | from bot.data import handle_message 9 | from bot.message import Message 10 | 11 | 12 | @handle_message(r'.*\bth[oi]nk(?:ing)?\b', flags=re.IGNORECASE) 13 | async def msg_think(config: Config, msg: Message) -> str | None: 14 | if random.randrange(0, 100) < 90: 15 | return None 16 | return format_msg(msg, 'awcPythonk ' * 5) 17 | -------------------------------------------------------------------------------- /bot/plugins/bonk.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from bot.config import Config 4 | from bot.data import command 5 | from bot.data import esc 6 | from bot.data import format_msg 7 | from bot.message import Message 8 | 9 | 10 | @command('!bonk') 11 | async def cmd_bonk(config: Config, msg: Message) -> str: 12 | _, _, rest = msg.msg.partition(' ') 13 | rest = rest.strip() or 'marsha_socks' 14 | return format_msg( 15 | msg, 16 | f'awcBonk awcBonk awcBonk {esc(rest)} awcBonk awcBonk awcBonk', 17 | ) 18 | -------------------------------------------------------------------------------- /bot/plugins/still.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import random 5 | 6 | from bot.config import Config 7 | from bot.data import command 8 | from bot.data import esc 9 | from bot.data import format_msg 10 | from bot.message import Message 11 | 12 | 13 | @command('!still') 14 | async def cmd_still(config: Config, msg: Message) -> str: 15 | _, _, rest = msg.msg.partition(' ') 16 | year = datetime.date.today().year 17 | lol = random.choice(['LOL', 'LOLW', 'LMAO', 'NUUU']) 18 | return format_msg(msg, f'{esc(rest)}, in {year} - {lol}!') 19 | -------------------------------------------------------------------------------- /bot/plugins/bongo.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from bot.config import Config 4 | from bot.data import command 5 | from bot.data import esc 6 | from bot.data import format_msg 7 | from bot.message import Message 8 | 9 | 10 | @command('!bongo') 11 | async def cmd_bongo(config: Config, msg: Message) -> str: 12 | _, _, rest = msg.msg.partition(' ') 13 | rest = rest.strip() 14 | if rest: 15 | rest = f'{rest} ' 16 | 17 | return format_msg( 18 | msg, 19 | f'awcBongo awcBongo awcBongo {esc(rest)}awcBongo awcBongo awcBongo', 20 | ) 21 | -------------------------------------------------------------------------------- /bot/plugins/kevin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from bot.config import Config 4 | from bot.data import command 5 | from bot.data import format_msg 6 | from bot.message import Message 7 | 8 | 9 | @command('!kevin', '!hovsater', secret=True) 10 | async def cmd_kevin(config: Config, msg: Message) -> str: 11 | return format_msg(msg, "Kevin stop spending money you don't have") 12 | 13 | 14 | @command('!isatisfied', secret=True) 15 | async def cmd_isatisfied(config: Config, msg: Message) -> str: 16 | return format_msg(msg, "Keep spending money that Kevin doesn't have") 17 | 18 | 19 | @command('!wolfred', secret=True) 20 | async def cmd_wolfred(config: Config, msg: Message) -> str: 21 | return format_msg(msg, 'wolfred is just here for the taco meat') 22 | -------------------------------------------------------------------------------- /bot/emote.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import NamedTuple 4 | 5 | 6 | class EmotePosition(NamedTuple): 7 | start: int 8 | end: int 9 | emote: str 10 | 11 | @property 12 | def download_url(self) -> str: 13 | return f'https://static-cdn.jtvnw.net/emoticons/v2/{self.emote}/default/dark/3.0' # noqa: E501 14 | 15 | 16 | def parse_emote_info(s: str) -> list[EmotePosition]: 17 | if not s: 18 | return [] 19 | 20 | ret = [] 21 | for part in s.split('/'): 22 | emote, _, positions = part.partition(':') 23 | for pos in positions.split(','): 24 | start_s, _, end_s = pos.partition('-') 25 | ret.append(EmotePosition(int(start_s), int(end_s), emote)) 26 | ret.sort() 27 | return ret 28 | -------------------------------------------------------------------------------- /bot/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import NamedTuple 4 | 5 | 6 | class Config(NamedTuple): 7 | username: str 8 | channel: str 9 | oauth_token: str 10 | client_id: str 11 | airnow_api_key: str 12 | openweathermap_api_key: str 13 | 14 | @property 15 | def oauth_token_token(self) -> str: 16 | _, token = self.oauth_token.split(':', 1) 17 | return token 18 | 19 | def __repr__(self) -> str: 20 | return ( 21 | f'{type(self).__name__}(' 22 | f'username={self.username!r}, ' 23 | f'channel={self.channel!r}, ' 24 | f'oauth_token={"***"!r}, ' 25 | f'client_id={"***"!r}, ' 26 | f'airnow_api_key={"***"!r}, ' 27 | f'openweathermap_api_key={"***"!r}, ' 28 | f')' 29 | ) 30 | -------------------------------------------------------------------------------- /bot/plugins/so.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | from bot.config import Config 6 | from bot.data import command 7 | from bot.data import esc 8 | from bot.data import format_msg 9 | from bot.message import Message 10 | 11 | USERNAME_RE = re.compile(r'\w+') 12 | 13 | 14 | @command('!so', secret=True) 15 | async def cmd_shoutout(config: Config, msg: Message) -> str | None: 16 | channel = msg.optional_user_arg 17 | user_match = USERNAME_RE.match(channel) 18 | if not msg.is_moderator and msg.name_key != config.channel: 19 | return format_msg(msg, 'https://youtu.be/RfiQYRn7fBg') 20 | elif channel == msg.name_key or user_match is None: 21 | return None 22 | user = user_match[0] 23 | return format_msg( 24 | msg, 25 | f'you should check out https://twitch.tv/{esc(user)} !', 26 | ) 27 | -------------------------------------------------------------------------------- /tests/emote_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from bot.emote import EmotePosition 6 | from bot.emote import parse_emote_info 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ('s', 'expected'), 11 | ( 12 | ('', []), 13 | ('303330140:23-31', [EmotePosition(23, 31, '303330140')]), 14 | ('302498976_BW:0-15', [EmotePosition(0, 15, '302498976_BW')]), 15 | ( 16 | '300753352:36-45/303265469:0-7,9-16,18-25', 17 | [ 18 | EmotePosition(0, 7, '303265469'), 19 | EmotePosition(9, 16, '303265469'), 20 | EmotePosition(18, 25, '303265469'), 21 | EmotePosition(36, 45, '300753352'), 22 | ], 23 | ), 24 | ), 25 | ) 26 | def test_parse_emote_info(s, expected): 27 | assert parse_emote_info(s) == expected 28 | -------------------------------------------------------------------------------- /bot/twitch_api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | import aiohttp 6 | import async_lru 7 | 8 | 9 | @async_lru.alru_cache(maxsize=32) 10 | async def fetch_twitch_user( 11 | username: str, 12 | *, 13 | oauth_token: str, 14 | client_id: str, 15 | ) -> dict[str, Any] | None: 16 | url = f'https://api.twitch.tv/helix/users?login={username}' 17 | headers = { 18 | 'Authorization': f'Bearer {oauth_token}', 19 | 'Client-ID': client_id, 20 | } 21 | async with aiohttp.ClientSession() as session: 22 | async with session.get(url, headers=headers) as resp: 23 | json_resp = await resp.json() 24 | users = json_resp.get('data') 25 | if users: 26 | user, = users 27 | return user 28 | else: 29 | return None 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Anthony Sottile 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /bot/plugins/pep.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | from bot.config import Config 6 | from bot.data import command 7 | from bot.data import format_msg 8 | from bot.data import handle_message 9 | from bot.message import Message 10 | 11 | DIGITS_RE = re.compile(r'\d{1,4}\b') 12 | PEP_RE = re.compile(fr'!pep(?P{DIGITS_RE.pattern})') 13 | 14 | 15 | def _pep_msg(msg: Message, n_s: str) -> str: 16 | n = str(int(n_s)).zfill(4) 17 | return format_msg(msg, f'https://peps.python.org/pep-{n}/') 18 | 19 | 20 | @command('!pep') 21 | async def cmd_pep_no_arg(config: Config, msg: Message) -> str: 22 | _, _, rest = msg.msg.strip().partition(' ') 23 | digits_match = DIGITS_RE.match(rest) 24 | if digits_match is not None: 25 | return _pep_msg(msg, digits_match[0]) 26 | else: 27 | return format_msg(msg, '!pep: expected argument ') 28 | 29 | 30 | @handle_message(PEP_RE.pattern) 31 | async def cmd_pep(config: Config, msg: Message) -> str: 32 | match = PEP_RE.match(msg.msg) 33 | assert match is not None 34 | return _pep_msg(msg, match['pep_num']) 35 | -------------------------------------------------------------------------------- /bot/image_cache.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import os.path 5 | 6 | import aiohttp 7 | 8 | from bot.util import atomic_open 9 | 10 | CACHE = '.cache' 11 | 12 | 13 | @functools.lru_cache(maxsize=1) 14 | def _ensure_cache_gitignore() -> None: 15 | gitignore_path = os.path.join(CACHE, '.gitignore') 16 | if os.path.exists(gitignore_path): 17 | return 18 | with atomic_open(gitignore_path) as f: 19 | f.write(b'*\n') 20 | 21 | 22 | def local_image_path(subtype: str, name: str) -> str: 23 | return os.path.join(CACHE, subtype, f'{name}.png') 24 | 25 | 26 | async def download(subtype: str, name: str, url: str) -> None: 27 | img_path = local_image_path(subtype, name) 28 | if os.path.exists(img_path): 29 | return 30 | 31 | img_dir = os.path.join(CACHE, subtype) 32 | os.makedirs(img_dir, exist_ok=True) 33 | 34 | _ensure_cache_gitignore() 35 | 36 | async with aiohttp.ClientSession() as session: 37 | async with session.get(url) as resp: 38 | data = await resp.read() 39 | 40 | with atomic_open(img_path) as f: 41 | f.write(data) 42 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: debug-statements 9 | - id: double-quote-string-fixer 10 | - id: name-tests-test 11 | - id: requirements-txt-fixer 12 | - repo: https://github.com/asottile/reorder-python-imports 13 | rev: v3.16.0 14 | hooks: 15 | - id: reorder-python-imports 16 | args: [--py312-plus, --add-import, 'from __future__ import annotations'] 17 | - repo: https://github.com/asottile/add-trailing-comma 18 | rev: v4.0.0 19 | hooks: 20 | - id: add-trailing-comma 21 | - repo: https://github.com/asottile/pyupgrade 22 | rev: v3.21.2 23 | hooks: 24 | - id: pyupgrade 25 | args: [--py312-plus] 26 | - repo: https://github.com/hhatto/autopep8 27 | rev: v2.3.2 28 | hooks: 29 | - id: autopep8 30 | - repo: https://github.com/PyCQA/flake8 31 | rev: 7.3.0 32 | hooks: 33 | - id: flake8 34 | - repo: https://github.com/pre-commit/mirrors-mypy 35 | rev: v1.19.1 36 | hooks: 37 | - id: mypy 38 | -------------------------------------------------------------------------------- /bot/plugins/gnu.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import random 4 | import re 5 | 6 | from bot.config import Config 7 | from bot.data import COMMANDS 8 | from bot.data import esc 9 | from bot.data import format_msg 10 | from bot.data import handle_message 11 | from bot.message import Message 12 | 13 | GNU_RE = re.compile( 14 | r'.*?\b(?Pnano|linux|windows|emacs|NT)\b', flags=re.IGNORECASE, 15 | ) 16 | 17 | # XXX: this doesn't belong here, but ordering is important 18 | 19 | 20 | @handle_message( 21 | '.*(why|is (this|that)|you us(e|ing)|instead of) (n?vim|nano)', 22 | flags=re.IGNORECASE, 23 | ) 24 | async def msg_is_this_vim(config: Config, msg: Message) -> str | None: 25 | return await COMMANDS['!editor'](config, msg) 26 | 27 | 28 | @handle_message(GNU_RE.pattern, flags=re.IGNORECASE) 29 | async def msg_gnu_please(config: Config, msg: Message) -> str | None: 30 | if random.randrange(0, 100) < 90: 31 | return None 32 | 33 | match = GNU_RE.match(msg.msg) 34 | assert match is not None 35 | word = match['word'] 36 | query = re.search(f'gnu[/+]{word}', msg.msg, flags=re.IGNORECASE) 37 | if query: 38 | return format_msg(msg, f'YES! {query[0]}') 39 | else: 40 | return format_msg(msg, f"Um please, it's GNU+{esc(word)}!") 41 | -------------------------------------------------------------------------------- /bot/plugins/uptime.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | 5 | import aiohttp 6 | 7 | from bot.config import Config 8 | from bot.data import command 9 | from bot.data import format_msg 10 | from bot.message import Message 11 | from bot.util import seconds_to_readable 12 | 13 | 14 | @command('!uptime') 15 | async def cmd_uptime(config: Config, msg: Message) -> str: 16 | url = f'https://api.twitch.tv/helix/streams?user_login={config.channel}' 17 | headers = { 18 | 'Authorization': f'Bearer {config.oauth_token_token}', 19 | 'Client-ID': config.client_id, 20 | } 21 | async with aiohttp.ClientSession() as session: 22 | async with session.get(url, headers=headers) as response: 23 | json_resp = await response.json() 24 | if not json_resp['data']: 25 | return format_msg(msg, 'not currently streaming!') 26 | start_time_s = json_resp['data'][0]['started_at'] 27 | start_time = datetime.datetime.strptime( 28 | start_time_s, '%Y-%m-%dT%H:%M:%SZ', 29 | ) 30 | elapsed = (datetime.datetime.utcnow() - start_time).seconds 31 | 32 | readable_time = seconds_to_readable(elapsed) 33 | return format_msg(msg, f'streaming for: {readable_time}') 34 | -------------------------------------------------------------------------------- /bot/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio.subprocess 4 | import contextlib 5 | import os.path 6 | import tempfile 7 | from collections.abc import Generator 8 | from typing import IO 9 | 10 | 11 | def get_quantified_unit(unit: str, amount: int) -> str: 12 | if amount == 1: 13 | return unit 14 | else: 15 | return f'{unit}s' 16 | 17 | 18 | def seconds_to_readable(seconds: int) -> str: 19 | parts = [] 20 | for n, unit in ( 21 | (60 * 60, 'hour'), 22 | (60, 'minute'), 23 | (1, 'second'), 24 | ): 25 | if seconds // n: 26 | unit = get_quantified_unit(unit, seconds // n) 27 | parts.append(f'{seconds // n} {unit}') 28 | seconds %= n 29 | return ', '.join(parts) 30 | 31 | 32 | @contextlib.contextmanager 33 | def atomic_open(filename: str) -> Generator[IO[bytes]]: 34 | fd, fname = tempfile.mkstemp(dir=os.path.dirname(filename)) 35 | try: 36 | with open(fd, 'wb') as f: 37 | yield f 38 | os.replace(fname, filename) 39 | except BaseException: 40 | os.remove(fname) 41 | raise 42 | 43 | 44 | async def check_call(*cmd: str) -> None: 45 | proc = await asyncio.subprocess.create_subprocess_exec( 46 | *cmd, stdout=asyncio.subprocess.DEVNULL, 47 | ) 48 | await proc.communicate() 49 | if proc.returncode != 0: 50 | raise ValueError(cmd, proc.returncode) 51 | -------------------------------------------------------------------------------- /bot/plugins/smarts.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import re 5 | 6 | from bot.config import Config 7 | from bot.data import COMMANDS 8 | from bot.data import handle_message 9 | from bot.message import Message 10 | 11 | 12 | def _reg(s: str) -> str: 13 | return fr".*what(['’]?s| is| does)?( this| that| the| your)? {s}\b" 14 | 15 | 16 | async def _base(config: Config, msg: Message, *, cmd: str) -> str | None: 17 | return await COMMANDS[cmd](config, msg) 18 | 19 | 20 | THINGS_TO_COMMANDS = ( 21 | ('advent of code', '!aoc'), 22 | ('aoc', '!aoc'), 23 | ('are (you|we) (building|doing|working on|making)', '!today'), 24 | ('babb?ie?', '!babi'), 25 | ('blue ball', '!bluething'), 26 | ('blue button', '!bluething'), 27 | ('blue thing', '!bluething'), 28 | ('books?', '!book'), 29 | ('code editor', '!editor'), 30 | ('color scheme', '!theme'), 31 | ('deadsnakes', '!deadsnakes'), 32 | ('distro', '!distro'), 33 | ('editor', '!editor'), 34 | ('keyboard', '!keyboard'), 35 | ('keypad', '!keyboard3'), 36 | ('os', '!os'), 37 | ('operating system', '!os'), 38 | ('playlist', '!playlist'), 39 | ('project', '!project'), 40 | ('text editor', '!editor'), 41 | ('theme', '!theme'), 42 | ('trackball', '!bluething'), 43 | ) 44 | 45 | for thing, command in THINGS_TO_COMMANDS: 46 | func = functools.partial(_base, cmd=command) 47 | handle_message(_reg(thing), flags=re.IGNORECASE)(func) 48 | -------------------------------------------------------------------------------- /bot/plugins/wideoidea.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import tempfile 5 | 6 | from bot.config import Config 7 | from bot.data import command 8 | from bot.data import format_msg 9 | from bot.message import Message 10 | from bot.util import check_call 11 | 12 | 13 | @command('!wideoidea', '!videoidea', secret=True) 14 | async def cmd_videoidea(config: Config, msg: Message) -> str: 15 | if not msg.is_moderator and msg.name_key != config.channel: 16 | return format_msg(msg, 'https://youtu.be/RfiQYRn7fBg') 17 | _, _, rest = msg.msg.partition(' ') 18 | 19 | async def _git(*cmd: str) -> None: 20 | await check_call('git', '-C', tmpdir, *cmd) 21 | 22 | with tempfile.TemporaryDirectory() as tmpdir: 23 | await _git( 24 | 'clone', '--depth=1', '--quiet', 25 | 'git@github.com:asottile/scratch.wiki', '.', 26 | ) 27 | ideas_file = os.path.join(tmpdir, 'anthony-explains-ideas.md') 28 | with open(ideas_file, 'rb+') as f: 29 | f.seek(-1, os.SEEK_END) 30 | c = f.read() 31 | if c != b'\n': 32 | f.write(b'\n') 33 | f.write(f'- {rest}\n'.encode()) 34 | await _git('add', '.') 35 | await _git('commit', '-q', '-m', 'idea added by !videoidea') 36 | await _git('push', '-q', 'origin', 'HEAD') 37 | 38 | return format_msg( 39 | msg, 40 | 'added! https://github.com/asottile/scratch/wiki/anthony-explains-ideas', # noqa: E501 41 | ) 42 | -------------------------------------------------------------------------------- /bot/plugins/aqi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | import aiohttp 6 | 7 | from bot.config import Config 8 | from bot.data import command 9 | from bot.data import esc 10 | from bot.data import format_msg 11 | from bot.message import Message 12 | 13 | 14 | ZIP_CODE_RE = re.compile(r'^\d{5}$', re.ASCII) 15 | 16 | 17 | @command('!aqi', secret=True) 18 | async def cmd_aqi(config: Config, msg: Message) -> str: 19 | _, _, rest = msg.msg.partition(' ') 20 | if rest: 21 | zip_code = rest.split()[0] 22 | if not ZIP_CODE_RE.match(zip_code): 23 | return format_msg(msg, '(invalid zip) usage: !aqi [US_ZIP_CODE]') 24 | else: 25 | zip_code = '48105' 26 | 27 | params = { 28 | 'format': 'application/json', 29 | 'zipCode': zip_code, 30 | 'API_KEY': config.airnow_api_key, 31 | } 32 | url = 'https://www.airnowapi.org/aq/observation/zipCode/current/' 33 | async with aiohttp.ClientSession() as session: 34 | async with session.get(url, params=params) as resp: 35 | json_resp = await resp.json() 36 | pm_25 = [d for d in json_resp if d['ParameterName'] == 'PM2.5'] 37 | if not pm_25: 38 | return format_msg( 39 | msg, 40 | 'No PM2.5 info -- is this a US zip code?', 41 | ) 42 | else: 43 | data, = pm_25 44 | return format_msg( 45 | msg, 46 | f'Current AQI ({esc(data["ParameterName"])}) in ' 47 | f'{esc(data["ReportingArea"])}, ' 48 | f'{esc(data["StateCode"])}: ' 49 | f'{esc(str(data["AQI"]))} ' 50 | f'({esc(data["Category"]["Name"])})', 51 | ) 52 | -------------------------------------------------------------------------------- /bot/plugins/today.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import aiosqlite 4 | 5 | from bot.config import Config 6 | from bot.data import command 7 | from bot.data import esc 8 | from bot.data import format_msg 9 | from bot.message import Message 10 | 11 | 12 | async def ensure_today_table_exists(db: aiosqlite.Connection) -> None: 13 | await db.execute( 14 | 'CREATE TABLE IF NOT EXISTS today (' 15 | ' msg TEXT NOT NULL,' 16 | ' timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP' 17 | ')', 18 | ) 19 | await db.commit() 20 | 21 | 22 | async def set_today(db: aiosqlite.Connection, msg: str) -> None: 23 | await ensure_today_table_exists(db) 24 | await db.execute('INSERT INTO today (msg) VALUES (?)', (msg,)) 25 | await db.commit() 26 | 27 | 28 | async def get_today(db: aiosqlite.Connection) -> str: 29 | await ensure_today_table_exists(db) 30 | query = 'SELECT msg FROM today ORDER BY ROWID DESC LIMIT 1' 31 | async with db.execute(query) as cursor: 32 | row = await cursor.fetchone() 33 | if row is None: 34 | return 'not working on anything?' 35 | else: 36 | return esc(row[0]) 37 | 38 | 39 | @command('!today', '!project') 40 | async def cmd_today(config: Config, msg: Message) -> str: 41 | async with aiosqlite.connect('db.db') as db: 42 | return format_msg(msg, await get_today(db)) 43 | 44 | 45 | @command('!settoday', secret=True) 46 | async def cmd_settoday(config: Config, msg: Message) -> str: 47 | if not msg.is_moderator and msg.name_key != config.channel: 48 | return format_msg(msg, 'https://youtu.be/RfiQYRn7fBg') 49 | _, _, rest = msg.msg.partition(' ') 50 | 51 | async with aiosqlite.connect('db.db') as db: 52 | await set_today(db, rest) 53 | 54 | return format_msg(msg, 'updated!') 55 | -------------------------------------------------------------------------------- /bot/plugins/pronouns.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypedDict 4 | 5 | import aiohttp 6 | import async_lru 7 | 8 | from bot.config import Config 9 | from bot.data import command 10 | from bot.data import esc 11 | from bot.data import format_msg 12 | from bot.message import Message 13 | 14 | 15 | class UserData(TypedDict): 16 | channel_id: str 17 | channel_login: str 18 | pronoun_id: str 19 | alt_pronoun_id: str | None 20 | 21 | 22 | class PronounData(TypedDict): 23 | name: str 24 | subject: str 25 | object: str 26 | singular: bool 27 | 28 | 29 | async def _get_user_data(username: str) -> UserData | None: 30 | url = f'https://api.pronouns.alejo.io/v1/users/{username}' 31 | 32 | async with aiohttp.ClientSession() as session: 33 | async with session.get(url) as resp: 34 | if resp.status != 200: 35 | return None 36 | 37 | return (await resp.json()) 38 | 39 | 40 | @async_lru.alru_cache(maxsize=1) 41 | async def pronouns() -> dict[str, PronounData]: 42 | url = 'https://api.pronouns.alejo.io/v1/pronouns/' 43 | 44 | async with aiohttp.ClientSession() as session: 45 | async with session.get(url) as resp: 46 | return (await resp.json()) 47 | 48 | 49 | async def _get_user_pronouns(username: str) -> tuple[str, str] | None: 50 | user_data = await _get_user_data(username) 51 | 52 | if user_data is None: 53 | return None 54 | 55 | pronoun_data = (await pronouns())[user_data['pronoun_id']] 56 | return (pronoun_data['subject'], pronoun_data['object']) 57 | 58 | 59 | @command('!pronouns') 60 | async def cmd_pronouns(config: Config, msg: Message) -> str: 61 | # TODO: handle display name 62 | username = msg.optional_user_arg.lower() 63 | pronouns = await _get_user_pronouns(username) 64 | 65 | if pronouns is None: 66 | return format_msg(msg, f'user not found {esc(username)}') 67 | 68 | (subj, obj) = pronouns 69 | return format_msg(msg, f'{username}\'s pronouns are: {subj}/{obj}') 70 | -------------------------------------------------------------------------------- /tests/plugins/babi_theme_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from bot.plugins.babi_theme import json_with_comments 6 | from bot.plugins.babi_theme import safe_ish_plist_loads 7 | 8 | 9 | def test_json_with_comments_basic(): 10 | assert json_with_comments(b'{}') == {} 11 | 12 | 13 | def test_json_with_comments_removes_inline_comment(): 14 | s = b'''\ 15 | { 16 | "//foo": "bar" // baz 17 | } 18 | ''' 19 | assert json_with_comments(s) == {'//foo': 'bar'} 20 | 21 | 22 | def test_json_with_comments_removes_inline_trailing_comma(): 23 | s = b'["a,],}",]' 24 | assert json_with_comments(s) == ['a,],}'] 25 | 26 | 27 | def test_json_with_comments_removes_non_inline_trailing_comma(): 28 | s = b''' 29 | { 30 | "foo,],}": "bar,],}", // hello ,],} 31 | } 32 | ''' 33 | assert json_with_comments(s) == {'foo,],}': 'bar,],}'} 34 | 35 | 36 | def test_plist_loads_works(): 37 | src = b'''\ 38 | 39 | 40 | 41 | 42 | hello 43 | world 44 | 45 | 46 | ''' # noqa: E501 47 | assert safe_ish_plist_loads(src) == {'hello': 'world'} 48 | 49 | 50 | def test_plist_loads_ignores_entities(): 51 | src = b'''\ 52 | 53 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | ]> 64 | &lol9; 65 | ''' 66 | with pytest.raises(ValueError): 67 | safe_ish_plist_loads(src) 68 | -------------------------------------------------------------------------------- /bot/cheer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from collections.abc import Mapping 5 | from typing import Any 6 | from typing import NamedTuple 7 | 8 | import aiohttp 9 | import async_lru 10 | 11 | from bot.twitch_api import fetch_twitch_user 12 | 13 | 14 | class CheerTier(NamedTuple): 15 | min_bits: int 16 | color: str 17 | image: str 18 | 19 | @classmethod 20 | def from_dct(cls, dct: dict[str, Any]) -> CheerTier: 21 | return cls( 22 | min_bits=dct['min_bits'], 23 | color=dct['color'], 24 | image=dct['images']['dark']['animated']['2'], 25 | ) 26 | 27 | 28 | class CheerInfo(NamedTuple): 29 | prefix: str 30 | tiers: tuple[CheerTier, ...] 31 | 32 | @classmethod 33 | def from_dct(cls, dct: dict[str, Any]) -> CheerInfo: 34 | tiers = tuple( 35 | CheerTier.from_dct(tier_dct) 36 | for tier_dct in dct['tiers'] 37 | if tier_dct['can_cheer'] or dct['prefix'].lower() == 'anon' 38 | ) 39 | return cls(prefix=dct['prefix'].lower(), tiers=tiers) 40 | 41 | 42 | @async_lru.alru_cache(maxsize=1) 43 | async def cheer_emotes( 44 | channel: str, 45 | *, 46 | oauth_token: str, 47 | client_id: str, 48 | ) -> tuple[re.Pattern[str], Mapping[str, CheerInfo]]: 49 | user = await fetch_twitch_user( 50 | channel, 51 | oauth_token=oauth_token, 52 | client_id=client_id, 53 | ) 54 | assert user is not None 55 | 56 | url = f'https://api.twitch.tv/helix/bits/cheermotes?broadcaster_id={user["id"]}' # noqa: E501 57 | headers = { 58 | 'Authorization': f'Bearer {oauth_token}', 59 | 'Client-ID': client_id, 60 | } 61 | async with aiohttp.ClientSession() as session: 62 | async with session.get(url, headers=headers) as resp: 63 | data = await resp.json() 64 | 65 | infos = (CheerInfo.from_dct(dct) for dct in data['data']) 66 | cheer_info = {info.prefix: info for info in infos if info.tiers} 67 | joined = '|'.join(re.escape(k) for k in cheer_info) 68 | reg = re.compile(fr'(?:^|(?<=\s))({joined})(\d+)(?=\s|$)', re.ASCII | re.I) 69 | return reg, cheer_info 70 | -------------------------------------------------------------------------------- /bot/plugins/motd.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import aiosqlite 4 | 5 | from bot.config import Config 6 | from bot.data import channel_points_handler 7 | from bot.data import command 8 | from bot.data import esc 9 | from bot.data import format_msg 10 | from bot.message import Message 11 | 12 | 13 | async def ensure_motd_table_exists(db: aiosqlite.Connection) -> None: 14 | await db.execute( 15 | 'CREATE TABLE IF NOT EXISTS motd (' 16 | ' user TEXT NOT NULL,' 17 | ' msg TEXT NOT NULL,' 18 | ' points INT NOT NULL,' 19 | ' timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP' 20 | ')', 21 | ) 22 | await db.commit() 23 | 24 | 25 | async def set_motd(db: aiosqlite.Connection, user: str, msg: str) -> None: 26 | await ensure_motd_table_exists(db) 27 | query = 'INSERT INTO motd (user, msg, points) VALUES (?, ?, ?)' 28 | await db.execute(query, (user, msg, 250)) 29 | await db.commit() 30 | 31 | 32 | async def get_motd(db: aiosqlite.Connection) -> str: 33 | await ensure_motd_table_exists(db) 34 | query = 'SELECT msg FROM motd ORDER BY ROWID DESC LIMIT 1' 35 | async with db.execute(query) as cursor: 36 | row = await cursor.fetchone() 37 | if row is None: 38 | return 'nothing???' 39 | else: 40 | return esc(row[0]) 41 | 42 | 43 | async def msg_count(db: aiosqlite.Connection, msg: str) -> int: 44 | await ensure_motd_table_exists(db) 45 | query = 'SELECT COUNT(1) FROM motd WHERE msg = ?' 46 | async with db.execute(query, (msg,)) as cursor: 47 | ret, = await cursor.fetchone() 48 | return ret 49 | 50 | 51 | @channel_points_handler('a2fa47a2-851e-40db-b909-df001801cade') 52 | async def cmd_set_motd(config: Config, msg: Message) -> str: 53 | async with aiosqlite.connect('db.db') as db: 54 | await set_motd(db, msg.name_key, msg.msg) 55 | s = 'motd updated! thanks for spending points!' 56 | if msg.msg == '!motd': 57 | motd_count = await msg_count(db, msg.msg) 58 | s = f'{s} it has been set to !motd {motd_count} times!' 59 | return format_msg(msg, s) 60 | 61 | 62 | @command('!motd') 63 | async def cmd_motd(config: Config, msg: Message) -> str: 64 | async with aiosqlite.connect('db.db') as db: 65 | return format_msg(msg, await get_motd(db)) 66 | -------------------------------------------------------------------------------- /bot/plugins/weather.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | import aiohttp 6 | 7 | from bot.config import Config 8 | from bot.data import command 9 | from bot.data import esc 10 | from bot.data import format_msg 11 | from bot.message import Message 12 | 13 | 14 | ZIP_PLACE_RE = re.compile(r'^\d{4,5}(?:,?\w+)?', re.ASCII) 15 | 16 | 17 | def c2f(celsius: float) -> float: 18 | return celsius * 9 / 5 + 32 19 | 20 | 21 | @command('!weather', secret=True) 22 | async def cmd_weather(config: Config, msg: Message) -> str: 23 | _, _, rest = msg.msg.partition(' ') 24 | if rest: 25 | m = ZIP_PLACE_RE.match(rest) 26 | if not m: 27 | return format_msg( 28 | msg, 29 | '(invalid zip) usage: !weather [ZIP_CODE],[COUNTRY_CODE?]', 30 | ) 31 | zip_code = m.string 32 | else: 33 | zip_code = '48103,US' 34 | 35 | geocoding_url = 'http://api.openweathermap.org/geo/1.0/zip' 36 | weather_url = 'https://api.openweathermap.org/data/2.5/weather' 37 | geocoding_params = { 38 | 'zip': zip_code, 39 | 'appid': config.openweathermap_api_key, 40 | } 41 | async with aiohttp.ClientSession() as session: 42 | async with session.get(geocoding_url, params=geocoding_params) as resp: 43 | geocoding_resp = await resp.json() 44 | 45 | lat, lon = geocoding_resp.get('lat'), geocoding_resp.get('lon') 46 | if lat is None or lon is None: 47 | return format_msg(msg, 'Did not find this place...') 48 | 49 | weather_params = { 50 | 'lon': lon, 51 | 'lat': lat, 52 | 'appid': config.openweathermap_api_key, 53 | } 54 | async with session.get(weather_url, params=weather_params) as resp: 55 | json_resp = await resp.json() 56 | 57 | # need to convert from Kelvin 58 | temp_c = json_resp['main']['temp'] - 273.15 59 | feels_like_c = json_resp['main']['feels_like'] - 273.15 60 | description = json_resp['weather'][0]['main'].lower() 61 | place = geocoding_resp['name'] 62 | country = geocoding_resp['country'] 63 | text = ( 64 | f'The current weather in {esc(place)}, {esc(country)} is ' 65 | f'{esc(description)} with a temperature of {temp_c:.1f} °C ' 66 | f'({c2f(temp_c):.1f} °F) ' 67 | f'and a feels-like temperature of {feels_like_c:.1f} °C ' 68 | f'({c2f(feels_like_c):.1f}° F).' 69 | ) 70 | return format_msg(msg, text) 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/anthonywritescode/twitch-chat-bot/main.svg)](https://results.pre-commit.ci/latest/github/anthonywritescode/twitch-chat-bot/main) 2 | 3 | twitch-chat-bot 4 | =============== 5 | 6 | A hackety chat bot I wrote for my twitch stream. I wanted to learn asyncio 7 | and this felt like a decent project to dive in on. 8 | 9 | ## setup 10 | 11 | 1. Set up a configuration file 12 | 13 | ```json 14 | { 15 | "username": "...", 16 | "channel": "...", 17 | "oauth_token": "...", 18 | "client_id": "...", 19 | "airnow_api_key": "..." 20 | } 21 | ``` 22 | 23 | - `username`: the username of the bot account 24 | - `channel`: the irc channel to connect to, for twitch this is the same as 25 | the streamer's channel name 26 | - `oauth_token`: follow the directions [here][docs-irc] to get a token 27 | - `client_id`: set up an application for your chat bot [here][app-setup] 28 | - `airnow_api_key`: api key for https://airnowapi.org 29 | 30 | 1. Use python3.8 or newer and install the dependencies in `requirements.txt` 31 | 32 | ```bash 33 | virtualenv venv -ppython3.8 34 | venv/bin/pip install -r requirements.txt 35 | ``` 36 | 37 | 1. Run! `venv/bin/python -m bot` 38 | 39 | [docs-irc]: https://dev.twitch.tv/docs/irc/ 40 | [app-setup]: https://dev.twitch.tv/docs/authentication/#registration 41 | [youtube-setup]: https://console.developers.google.com/apis/credentials 42 | 43 | ## implemented commands 44 | 45 | ### `!help` 46 | 47 | List all the currently supported commands 48 | 49 | ``` 50 | anthonywritescode: !help 51 | anthonywritescodebot: possible commands: !help, !ohai, !uptime 52 | ``` 53 | 54 | ### `!ohai` 55 | 56 | Greet yo self 57 | 58 | ``` 59 | anthonywritescode: !ohai 60 | anthonywritescodebot: ohai, anthonywritescode! 61 | ``` 62 | 63 | ### `!uptime` 64 | 65 | Show how long the stream has been running for 66 | 67 | ``` 68 | anthonywritescode: !uptime 69 | anthonywritescodebot: streaming for: 3 hours, 57 minutes, 17 seconds 70 | ``` 71 | 72 | ### `PING ...` 73 | 74 | Replies `PONG` to whatever you say 75 | 76 | ``` 77 | anthonywritescode: PING 78 | anthonywritescodebot: PONG 79 | anthonywritescode: PING hello 80 | anthonywritescodebot: PONG hello 81 | ``` 82 | 83 | ### `!discord` 84 | 85 | Show the discord url 86 | 87 | ``` 88 | anthonywritescode: !discord 89 | anthonywritescodebot: We do have Discord, you are welcome to join: https://discord.gg/xDKGPaW 90 | ``` 91 | 92 | ### `!followage [username]` 93 | 94 | Show how long you or a user you specified have been following the channel 95 | 96 | ``` 97 | not_cool_user: !followage 98 | anthonywritescodebot: not_cool_user is not a follower! 99 | 100 | cool_user: !followage 101 | anthonywritescodebot: cool_user has been following for 3 hours! 102 | 103 | some_user: !followage another_user 104 | anthonywritescodebot: another_user has been following for 5 years! 105 | ``` 106 | 107 | ### `!joke` 108 | 109 | Get a joke 110 | 111 | ``` 112 | anthonywritescode: !joke 113 | anthonywritescodebot: The best thing about a Boolean is even if you are wrong, you are only off by a bit. 114 | ``` 115 | -------------------------------------------------------------------------------- /bot/plugins/giveaway.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import random 4 | 5 | import aiosqlite 6 | 7 | from bot.config import Config 8 | from bot.data import command 9 | from bot.data import esc 10 | from bot.data import format_msg 11 | from bot.message import Message 12 | 13 | 14 | async def ensure_giveaway_tables_exist(db: aiosqlite.Connection) -> None: 15 | await db.execute( 16 | 'CREATE TABLE IF NOT EXISTS giveaway (' 17 | ' active BIT NOT NULL,' 18 | ' PRIMARY KEY (active)' 19 | ')', 20 | ) 21 | await db.execute( 22 | 'CREATE TABLE IF NOT EXISTS giveaway_users (' 23 | ' user TEXT NOT NULL,' 24 | ' PRIMARY KEY (user)' 25 | ')', 26 | ) 27 | await db.commit() 28 | 29 | 30 | @command('!giveawaystart', secret=True) 31 | async def givewawaystart(config: Config, msg: Message) -> str | None: 32 | if not msg.is_moderator and msg.name_key != config.channel: 33 | return None 34 | 35 | async with aiosqlite.connect('db.db') as db: 36 | await ensure_giveaway_tables_exist(db) 37 | 38 | await db.execute('INSERT OR REPLACE INTO giveaway VALUES (1)') 39 | await db.commit() 40 | 41 | return format_msg(msg, 'giveaway started! use !giveaway to enter') 42 | 43 | 44 | @command('!giveaway', secret=True) 45 | async def giveaway(config: Config, msg: Message) -> str: 46 | async with aiosqlite.connect('db.db') as db: 47 | await ensure_giveaway_tables_exist(db) 48 | 49 | async with db.execute('SELECT active FROM giveaway') as cursor: 50 | row = await cursor.fetchone() 51 | if row is None or not row[0]: 52 | return format_msg(msg, 'no current giveaway active!') 53 | 54 | await ensure_giveaway_tables_exist(db) 55 | query = 'INSERT OR REPLACE INTO giveaway_users VALUES (?)' 56 | await db.execute(query, (msg.display_name,)) 57 | await db.commit() 58 | 59 | return format_msg(msg, f'{esc(msg.display_name)} has been entered!') 60 | 61 | 62 | @command('!giveawayend', secret=True) 63 | async def giveawayend(config: Config, msg: Message) -> str | None: 64 | if not msg.is_moderator and msg.name_key != config.channel: 65 | return None 66 | 67 | async with aiosqlite.connect('db.db') as db: 68 | await ensure_giveaway_tables_exist(db) 69 | 70 | async with db.execute('SELECT active FROM giveaway') as cursor: 71 | row = await cursor.fetchone() 72 | if row is None or not row[0]: 73 | return format_msg(msg, 'no current giveaway active!') 74 | 75 | query = 'SELECT user FROM giveaway_users' 76 | async with db.execute(query) as cursor: 77 | users = [user for user, in await cursor.fetchall()] 78 | 79 | if users: 80 | await db.execute('INSERT OR REPLACE INTO giveaway VALUES (0)') 81 | await db.commit() 82 | 83 | await db.execute('DROP TABLE giveaway_users') 84 | await db.execute('DROP TABLE giveaway') 85 | await db.commit() 86 | 87 | if not users: 88 | return format_msg(msg, 'no users entered giveaway!') 89 | 90 | winner = random.choice(users) 91 | return format_msg(msg, f'!giveaway winner is {esc(winner)}') 92 | -------------------------------------------------------------------------------- /bot/message.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | import re 5 | import struct 6 | from typing import NamedTuple 7 | 8 | ME_PREFIX = '\x01ACTION ' 9 | MSG_RE = re.compile( 10 | '^@(?P[^ ]+) :(?P[^!]+).* ' 11 | 'PRIVMSG #(?P[^ ]+) ' 12 | ':(?P[^\r]+)', 13 | ) 14 | 15 | 16 | def parse_color(s: str) -> tuple[int, int, int]: 17 | return int(s[1:3], 16), int(s[3:5], 16), int(s[5:7], 16) 18 | 19 | 20 | def _gen_color(name: str) -> tuple[int, int, int]: 21 | h = hashlib.sha256(name.encode()) 22 | n, = struct.unpack('Q', h.digest()[:8]) 23 | bits = [int(s) for s in bin(n)[2:]] 24 | 25 | r = bits[0] * 0b1111111 + (bits[1] << 7) 26 | g = bits[2] * 0b1111111 + (bits[3] << 7) 27 | b = bits[4] * 0b1111111 + (bits[5] << 7) 28 | return r, g, b 29 | 30 | 31 | class Message(NamedTuple): 32 | msg: str 33 | is_me: bool 34 | channel: str 35 | info: dict[str, str] 36 | 37 | @property 38 | def badges(self) -> tuple[str, ...]: 39 | return tuple(self.info['badges'].split(',')) 40 | 41 | @property 42 | def display_name(self) -> str: 43 | return self.info['display-name'] 44 | 45 | @property 46 | def name_key(self) -> str: 47 | """compat with old match['msg']""" 48 | return self.display_name.lower() 49 | 50 | @property 51 | def color(self) -> tuple[int, int, int]: 52 | if self.info['color']: 53 | return parse_color(self.info['color']) 54 | else: 55 | return _gen_color(self.display_name) 56 | 57 | @property 58 | def bg_color(self) -> tuple[int, int, int] | None: 59 | if self.info.get('msg-id') == 'highlighted-message': 60 | return (117, 94, 188) 61 | elif self.info.get('custom-reward-id'): 62 | return 29, 91, 130 63 | else: 64 | return None 65 | 66 | @property 67 | def optional_user_arg(self) -> str: 68 | _, _, rest = self.msg.strip().partition(' ') 69 | if rest: 70 | return rest.lstrip('@') 71 | else: 72 | return self.display_name 73 | 74 | @property 75 | def is_moderator(self) -> bool: 76 | return any(badge.startswith('moderator/') for badge in self.badges) 77 | 78 | @property 79 | def is_subscriber(self) -> bool: 80 | possible = ('founder/', 'subscriber/') 81 | return any(badge.startswith(possible) for badge in self.badges) 82 | 83 | @classmethod 84 | def parse(cls, msg: str) -> Message | None: 85 | match = MSG_RE.match(msg) 86 | if match is not None: 87 | is_me = match['msg'].startswith(ME_PREFIX) 88 | if is_me: 89 | msg = match['msg'][len(ME_PREFIX):] 90 | else: 91 | msg = match['msg'] 92 | 93 | info = {} 94 | for part in match['info'].split(';'): 95 | k, v = part.split('=', 1) 96 | info[k] = v 97 | return cls( 98 | msg=msg, 99 | is_me=is_me, 100 | channel=match['channel'], 101 | info=info, 102 | ) 103 | else: 104 | return None 105 | -------------------------------------------------------------------------------- /tests/plugins/chatrank_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import Counter 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | 8 | from bot.plugins import chatrank 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ('username', 'counts', 'expected'), 13 | ( 14 | pytest.param( 15 | 'this_user_does_not_exist', 16 | Counter(), 17 | None, 18 | id='non-existing user and empty rank counts', 19 | ), 20 | pytest.param( 21 | 'this_user_does_not_exist', 22 | Counter({ 23 | 'rank_1_user': 69, 24 | 'rank_2_user': 42, 25 | 'another_rank_2_user': 42, 26 | }), 27 | None, 28 | id='non-existing user with non-empty rank counts', 29 | ), 30 | pytest.param( 31 | 'rank_1_user', 32 | Counter({ 33 | 'rank_1_user': 69, 34 | 'rank_2_user': 42, 35 | 'another_rank_2_user': 42, 36 | }), 37 | (1, 69), 38 | id='the only user with the highest messages count on the #1 rank', 39 | ), 40 | pytest.param( 41 | 'rank_2_user', 42 | Counter({ 43 | 'rank_1_user': 69, 44 | 'rank_2_user': 42, 45 | 'another_rank_2_user': 42, 46 | }), 47 | (2, 42), 48 | id='the first of several users on the #2 rank with 42 messages', 49 | ), 50 | pytest.param( 51 | 'another_rank_2_user', 52 | Counter({ 53 | 'rank_1_user': 69, 54 | 'rank_2_user': 42, 55 | 'another_rank_2_user': 42, 56 | }), 57 | (2, 42), 58 | id='the second of several users on the #2 rank with 42 messages', 59 | ), 60 | ), 61 | ) 62 | def test_user_rank_by_line_type(username, counts, expected): 63 | with patch.object(chatrank, '_chat_rank_counts', return_value=counts): 64 | # the second parameter does not really affect the ranking logic, 65 | # so we always use chatrank.CHAT_LOG_RE 66 | ret = chatrank._user_rank_by_line_type(username, chatrank.CHAT_LOG_RE) 67 | assert ret == expected 68 | 69 | 70 | @pytest.mark.parametrize( 71 | ('counts', 'n', 'expected'), 72 | ( 73 | (Counter(), 0, []), 74 | (Counter(), 1, []), 75 | ( 76 | Counter({ 77 | 'rank_1_user': 69, 78 | 'rank_2_user': 42, 79 | 'another_rank_2_user': 42, 80 | }), 81 | 0, 82 | [], 83 | ), 84 | ( 85 | Counter({ 86 | 'rank_1_user': 69, 87 | 'rank_2_user': 42, 88 | 'another_rank_2_user': 42, 89 | }), 90 | 1, 91 | ['1. rank_1_user (69)'], 92 | ), 93 | ( 94 | Counter({ 95 | 'rank_1_user': 69, 96 | 'rank_2_user': 42, 97 | 'another_rank_2_user': 42, 98 | }), 99 | 3, 100 | [ 101 | '1. rank_1_user (69)', 102 | '2. rank_2_user, another_rank_2_user (42)', 103 | ], 104 | ), 105 | ( 106 | Counter({ 107 | 'rank_1_user': 69, 108 | 'rank_2_user': 42, 109 | 'another_rank_2_user': 42, 110 | }), 111 | 999, 112 | [ 113 | '1. rank_1_user (69)', 114 | '2. rank_2_user, another_rank_2_user (42)', 115 | ], 116 | ), 117 | ), 118 | ) 119 | def test_top_n_rank_by_line_type(counts, n, expected): 120 | with patch.object(chatrank, '_chat_rank_counts', return_value=counts): 121 | # the second parameter does not really affect the ranking logic, 122 | # so we always use chatrank.CHAT_LOG_RE 123 | ret = chatrank._top_n_rank_by_line_type(chatrank.CHAT_LOG_RE, n=n) 124 | assert ret == expected 125 | -------------------------------------------------------------------------------- /bot/plugins/youtube_playlist_search.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import NamedTuple 4 | 5 | import aiohttp 6 | import aiosqlite 7 | import async_lru 8 | 9 | from bot.config import Config 10 | from bot.data import command 11 | from bot.data import esc 12 | from bot.data import format_msg 13 | from bot.message import Message 14 | 15 | 16 | class Playlist(NamedTuple): 17 | name: str 18 | id: str 19 | 20 | @property 21 | def url(self) -> str: 22 | return f'https://www.youtube.com/playlist?list={self.id}' 23 | 24 | 25 | class YouTubeVideo(NamedTuple): 26 | playlist: str 27 | url: str 28 | title: str 29 | 30 | def chat_message(self) -> str: 31 | return f'{esc(self.title)} - {esc(self.url)}' 32 | 33 | 34 | @async_lru.alru_cache(maxsize=None) 35 | async def _info() -> tuple[tuple[Playlist, ...], tuple[YouTubeVideo, ...]]: 36 | async with aiohttp.ClientSession() as session: 37 | async with session.get('https://anthonywritescode.github.io/explains/playlists.json') as resp: # noqa: E501 38 | resp = await resp.json() 39 | 40 | playlists = tuple( 41 | Playlist(playlist['playlist_name'], playlist['playlist_id']) 42 | for playlist in resp['playlists'] 43 | ) 44 | 45 | videos = tuple( 46 | YouTubeVideo(playlist['playlist_name'], **video) 47 | for playlist in resp['playlists'] 48 | for video in playlist['videos'] 49 | ) 50 | 51 | return playlists, videos 52 | 53 | 54 | @async_lru.alru_cache(maxsize=None) 55 | async def _populate_playlists() -> None: 56 | async with aiosqlite.connect('db.db') as db: 57 | await db.execute('DROP TABLE IF EXISTS youtube_videos') 58 | await db.execute( 59 | 'CREATE VIRTUAL TABLE youtube_videos using FTS5 ' 60 | '(playlist, url, title)', 61 | ) 62 | await db.commit() 63 | 64 | _, videos = await _info() 65 | 66 | query = 'INSERT INTO youtube_videos VALUES (?, ?, ?)' 67 | await db.executemany(query, videos) 68 | 69 | await db.commit() 70 | 71 | 72 | async def _playlist(playlist_name: str) -> Playlist: 73 | playlists, _ = await _info() 74 | playlist, = (p for p in playlists if p.name == playlist_name) 75 | return playlist 76 | 77 | 78 | async def _search_playlist( 79 | db: aiosqlite.Connection, 80 | playlist: str, 81 | search_terms: str, 82 | ) -> list[YouTubeVideo]: 83 | query = ( 84 | 'SELECT playlist, url, title ' 85 | 'FROM youtube_videos ' 86 | 'WHERE playlist = ? AND title MATCH ? ORDER BY rank' 87 | ) 88 | # Append a wildcard character to the search to include plurals etc. 89 | if not search_terms.endswith('*'): 90 | search_terms += '*' 91 | async with db.execute(query, (playlist, search_terms)) as cursor: 92 | results = await cursor.fetchall() 93 | return [YouTubeVideo(*row) for row in results] 94 | 95 | 96 | async def _msg(playlist_name: str, search_terms: str) -> str: 97 | await _populate_playlists() 98 | 99 | playlist = await _playlist(playlist_name) 100 | 101 | if not search_terms.strip(): 102 | return f'see playlist: {playlist.url}' 103 | 104 | async with aiosqlite.connect('db.db') as db: 105 | try: 106 | videos = await _search_playlist(db, playlist_name, search_terms) 107 | except aiosqlite.OperationalError: 108 | return 'invalid search syntax used' 109 | 110 | if not videos: 111 | return f'no video found - see playlist: {playlist.url}' 112 | elif len(videos) > 2: 113 | return ( 114 | f'{videos[0].chat_message()} and {len(videos)} other ' 115 | f'videos found - see playlist: {playlist.url}' 116 | ) 117 | elif len(videos) == 2: 118 | return ( 119 | '2 videos found: ' 120 | f'{videos[0].chat_message()} & {videos[1].chat_message()}' 121 | ) 122 | else: 123 | return videos[0].chat_message() 124 | 125 | 126 | @command('!explain', '!explains') 127 | async def cmd_explain(config: Config, msg: Message) -> str: 128 | _, _, rest = msg.msg.partition(' ') 129 | return format_msg(msg, await _msg('explains', rest)) 130 | 131 | 132 | @command('!faq') 133 | async def cmd_faq(config: Config, msg: Message) -> str: 134 | _, _, rest = msg.msg.partition(' ') 135 | return format_msg(msg, await _msg('faq', rest)) 136 | -------------------------------------------------------------------------------- /bot/parse_message.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import re 5 | from collections.abc import Generator 6 | from collections.abc import Mapping 7 | from typing import NamedTuple 8 | 9 | from bot.cheer import cheer_emotes 10 | from bot.cheer import CheerInfo 11 | from bot.emote import parse_emote_info 12 | from bot.image_cache import download 13 | from bot.image_cache import local_image_path 14 | from bot.message import Message 15 | from bot.message import parse_color 16 | 17 | 18 | def terminology_image(url: str, *, width: int, height: int) -> str: 19 | parts = [f'\033}}ic#{width};{height};{url}\000'] 20 | for _ in range(height): 21 | parts.append(f'\033}}ib\000{"#" * width}\033}}ie\000\n') 22 | return ''.join(parts).rstrip('\n') 23 | 24 | 25 | class Emote(NamedTuple): 26 | url: str 27 | original: str 28 | 29 | 30 | class Cheer(NamedTuple): 31 | url: str 32 | n: int 33 | color: str 34 | original: str 35 | 36 | 37 | def _replace_cheer( 38 | s: str, 39 | cheer_info: Mapping[str, CheerInfo], 40 | cheer_regex: re.Pattern[str], 41 | ) -> Generator[str | Emote | Cheer]: 42 | pos = 0 43 | for match in cheer_regex.finditer(s): 44 | yield s[pos:match.start()] 45 | n = int(match[2]) 46 | for tier in reversed(cheer_info[match[1].lower()].tiers): 47 | if n >= tier.min_bits: 48 | break 49 | yield Cheer( 50 | url=tier.image, 51 | n=n, 52 | color=tier.color, 53 | original=f'{match[1].lower()}{tier.min_bits}', 54 | ) 55 | pos = match.end() 56 | yield s[pos:] 57 | 58 | 59 | async def parse_message_parts( 60 | msg: Message, 61 | *, 62 | channel: str, 63 | oauth_token: str, 64 | client_id: str, 65 | ) -> list[str | Emote | Cheer]: 66 | emotes = parse_emote_info(msg.info['emotes']) 67 | 68 | parts: list[str | Emote | Cheer] = [] 69 | pos = 0 70 | for emote in emotes: 71 | parts.append(msg.msg[pos:emote.start]) 72 | parts.append(Emote(url=emote.download_url, original=emote.emote)) 73 | pos = emote.end + 1 74 | parts.append(msg.msg[pos:]) 75 | 76 | if 'bits' in msg.info: 77 | cheer_regex, cheer_info = await cheer_emotes( 78 | channel, 79 | oauth_token=oauth_token, 80 | client_id=client_id, 81 | ) 82 | new_parts: list[str | Emote | Cheer] = [] 83 | for part in parts: 84 | if isinstance(part, str): 85 | new_parts.extend(_replace_cheer(part, cheer_info, cheer_regex)) 86 | else: 87 | new_parts.append(part) 88 | parts = new_parts 89 | 90 | return parts 91 | 92 | _033 = '(?:033|x1b)' 93 | _0_107 = '(?:10[0-7]|[0-9]?[0-9]?)' 94 | _0_255 = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[0-9]?[0-9])' 95 | _COLORIZE_ALLOWED = re.compile( 96 | fr'\\{_033}\[{_0_107}m|' 97 | fr'\\{_033}\[[345]8;5;{_0_255}m|' 98 | fr'\\{_033}\[[345]8;2;{_0_255};{_0_255};{_0_255}m', 99 | ) 100 | 101 | 102 | def colorize(s: str) -> str: 103 | def replace_cb(m: re.Match[str]) -> str: 104 | return m[0].replace(r'\033', '\033').replace(r'\x1b', '\x1b') 105 | return f'{_COLORIZE_ALLOWED.sub(replace_cb, s)}\033[m' 106 | 107 | 108 | async def parsed_to_terminology( 109 | parts: list[str | Emote | Cheer], 110 | *, 111 | big: bool, 112 | ) -> str: 113 | futures = [] 114 | s_parts = [] 115 | 116 | last_emote = -1 117 | for i, part in enumerate(parts): 118 | if isinstance(part, Emote): 119 | last_emote = i 120 | 121 | for i, part in enumerate(parts): 122 | if isinstance(part, str): 123 | s_parts.append(part) 124 | elif isinstance(part, Emote): 125 | url = local_image_path('emote', part.original) 126 | if big and i == last_emote: 127 | s_parts.append('\n') 128 | s_parts.append(terminology_image(url, width=11, height=6)) 129 | else: 130 | s_parts.append(terminology_image(url, width=2, height=1)) 131 | futures.append(download('emote', part.original, part.url)) 132 | elif isinstance(part, Cheer): 133 | url = local_image_path('cheer', part.original) 134 | s_parts.append(terminology_image(url, width=2, height=1)) 135 | r, g, b = parse_color(part.color) 136 | s_parts.append(f'\033[1m\033[38;2;{r};{g};{b}m{part.n}\033[m') 137 | futures.append(download('cheer', part.original, part.url)) 138 | else: 139 | raise AssertionError(f'unexpected part: {part}') 140 | 141 | await asyncio.gather(*futures) 142 | return ''.join(s_parts) 143 | -------------------------------------------------------------------------------- /bot/badges.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import re 5 | from collections.abc import Mapping 6 | from typing import NamedTuple 7 | 8 | import aiohttp 9 | import async_lru 10 | 11 | from bot.image_cache import download 12 | from bot.image_cache import local_image_path 13 | from bot.parse_message import terminology_image 14 | from bot.twitch_api import fetch_twitch_user 15 | 16 | 17 | @async_lru.alru_cache(maxsize=1) 18 | async def global_badges( 19 | *, 20 | oauth_token: str, 21 | client_id: str, 22 | ) -> Mapping[str, Mapping[str, str]]: 23 | url = 'https://api.twitch.tv/helix/chat/badges/global' 24 | headers = { 25 | 'Authorization': f'Bearer {oauth_token}', 26 | 'Client-ID': client_id, 27 | } 28 | async with aiohttp.ClientSession() as session: 29 | async with session.get(url, headers=headers) as resp: 30 | data = await resp.json() 31 | 32 | return { 33 | v['set_id']: { 34 | version['id']: version['image_url_4x'] 35 | for version in v['versions'] 36 | } 37 | for v in data['data'] 38 | } 39 | 40 | 41 | @async_lru.alru_cache(maxsize=1) 42 | async def channel_badges( 43 | username: str, 44 | *, 45 | oauth_token: str, 46 | client_id: str, 47 | ) -> Mapping[str, Mapping[str, str]]: 48 | user = await fetch_twitch_user( 49 | username, 50 | oauth_token=oauth_token, 51 | client_id=client_id, 52 | ) 53 | assert user is not None 54 | 55 | url = f'https://api.twitch.tv/helix/chat/badges?broadcaster_id={user["id"]}' # noqa: E501 56 | headers = { 57 | 'Authorization': f'Bearer {oauth_token}', 58 | 'Client-ID': client_id, 59 | } 60 | async with aiohttp.ClientSession() as session: 61 | async with session.get(url, headers=headers) as resp: 62 | data = await resp.json() 63 | 64 | return { 65 | v['set_id']: { 66 | version['id']: version['image_url_4x'] 67 | for version in v['versions'] 68 | } 69 | for v in data['data'] 70 | } 71 | 72 | 73 | @async_lru.alru_cache(maxsize=1) 74 | async def all_badges( 75 | username: str, 76 | *, 77 | oauth_token: str, 78 | client_id: str, 79 | ) -> Mapping[str, Mapping[str, str]]: 80 | return { 81 | **await global_badges(oauth_token=oauth_token, client_id=client_id), 82 | **await channel_badges( 83 | username, 84 | oauth_token=oauth_token, 85 | client_id=client_id, 86 | ), 87 | } 88 | 89 | 90 | def badges_plain_text(badges: tuple[str, ...]) -> str: 91 | ret = '' 92 | for s, reg in ( 93 | ('\033[48;2;000;000;000m⚙\033[m', re.compile('^staff/')), 94 | ('\033[48;2;000;173;003m⚔\033[m', re.compile('^moderator/')), 95 | ('\033[48;2;224;005;185m♦\033[m', re.compile('^vip/')), 96 | ('\033[48;2;233;025;022m☞\033[m', re.compile('^broadcaster/')), 97 | ('\033[48;2;130;005;180m★\033[m', re.compile('^founder/')), 98 | ('\033[48;2;130;005;180m★\033[m', re.compile('^subscriber/')), 99 | ('\033[48;2;000;160;214m♕\033[m', re.compile('^premium/')), 100 | ('\033[48;2;089;057;154m♕\033[m', re.compile('^turbo/')), 101 | ('\033[48;2;230;186;072m◘\033[m', re.compile('^sub-gift-leader/')), 102 | ('\033[48;2;088;226;193m◘\033[m', re.compile('^sub-gifter/')), 103 | ('\033[48;2;183;125;029m♕\033[m', re.compile('^hype-train/')), 104 | ('\033[48;2;203;200;208m▴\033[m', re.compile('^bits/')), 105 | ('\033[48;2;230;186;072m♦\033[m', re.compile('^bits-leader/')), 106 | ('\033[48;2;145;070;255m☑\033[m', re.compile('^partner/')), 107 | ): 108 | for badge in badges: 109 | if reg.match(badge): 110 | ret += s 111 | return ret 112 | 113 | 114 | class Badge(NamedTuple): 115 | badge: str 116 | version: str 117 | 118 | @property 119 | def local_filename(self) -> str: 120 | return f'{self.badge}_{self.version}.png' 121 | 122 | @property 123 | def fs_path(self) -> str: 124 | return local_image_path('badge', self.local_filename) 125 | 126 | 127 | def parse_badges(badges: str) -> list[Badge]: 128 | if not badges: 129 | return [] 130 | 131 | ret = [] 132 | for badge_s in badges.split(','): 133 | badge, version = badge_s.split('/', 1) 134 | ret.append(Badge(badge, version)) 135 | return ret 136 | 137 | 138 | async def download_all_badges( 139 | badges: list[Badge], 140 | *, 141 | channel: str, 142 | oauth_token: str, 143 | client_id: str, 144 | ) -> None: 145 | badges_mapping = await all_badges( 146 | channel, 147 | oauth_token=oauth_token, 148 | client_id=client_id, 149 | ) 150 | futures = [ 151 | download( 152 | subtype='badge', 153 | name=badge.local_filename, 154 | url=badges_mapping[badge.badge][badge.version], 155 | ) 156 | for badge in badges 157 | ] 158 | await asyncio.gather(*futures) 159 | 160 | 161 | def badges_images(badges: list[Badge]) -> str: 162 | return ''.join( 163 | terminology_image(badge.fs_path, width=2, height=1) 164 | for badge in badges 165 | ) 166 | -------------------------------------------------------------------------------- /bot/data.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import difflib 4 | import pkgutil 5 | import re 6 | from collections.abc import Awaitable 7 | from collections.abc import Callable 8 | from re import Pattern 9 | 10 | from bot import plugins 11 | from bot.config import Config 12 | from bot.message import Message 13 | 14 | # TODO: maybe move this? 15 | PRIVMSG = 'PRIVMSG #{channel} : {msg}\r\n' 16 | COMMAND_RE = re.compile(r'^(?P!+[a-zA-Z0-9-]+)') 17 | 18 | 19 | def get_fake_msg( 20 | config: Config, 21 | msg: str, 22 | *, 23 | bits: int = 0, 24 | mod: bool = False, 25 | user: str = 'username', 26 | ) -> str: 27 | badges = 'moderator/1' if mod else '' 28 | info = f'@badges={badges};bits={bits};color=;display-name={user}' 29 | return f'{info} :{user} PRIVMSG #{config.channel} :{msg}\r\n' 30 | 31 | 32 | # TODO: move this and/or delete this 33 | def esc(s: str) -> str: 34 | return s.replace('{', '{{').replace('}', '}}') 35 | 36 | 37 | def format_msg(msg: Message, fmt: str) -> str: 38 | params = {'user': msg.display_name, 'channel': msg.channel} 39 | params['msg'] = fmt.format(**params) 40 | return PRIVMSG.format(**params) 41 | 42 | 43 | Callback = Callable[[Config, Message], Awaitable[str | None]] 44 | MSG_HANDLERS: list[tuple[Pattern[str], Callback]] = [] 45 | COMMANDS: dict[str, Callback] = {} 46 | POINTS_HANDLERS: dict[str, Callback] = {} 47 | BITS_HANDLERS: dict[int, Callback] = {} 48 | SECRET_CMDS: set[str] = set() 49 | PERIODIC_HANDLERS: list[tuple[int, Callback]] = [] 50 | 51 | 52 | def handle_message( 53 | *message_prefixes: str, 54 | flags: re.RegexFlag = re.U, 55 | ) -> Callable[[Callback], Callback]: 56 | def handle_message_decorator(func: Callback) -> Callback: 57 | for prefix in message_prefixes: 58 | MSG_HANDLERS.append((re.compile(prefix, flags=flags), func)) 59 | 60 | return func 61 | return handle_message_decorator 62 | 63 | 64 | def command( 65 | *cmds: str, 66 | secret: bool = False, 67 | ) -> Callable[[Callback], Callback]: 68 | def command_decorator(func: Callback) -> Callback: 69 | for cmd in cmds: 70 | COMMANDS[cmd] = func 71 | if secret: 72 | SECRET_CMDS.update(cmds) 73 | else: 74 | SECRET_CMDS.update(cmds[1:]) 75 | return func 76 | return command_decorator 77 | 78 | 79 | def channel_points_handler(reward_id: str) -> Callable[[Callback], Callback]: 80 | def channel_points_handler_decorator(func: Callback) -> Callback: 81 | POINTS_HANDLERS[reward_id] = func 82 | return func 83 | return channel_points_handler_decorator 84 | 85 | 86 | def bits_handler(bits_mod: int) -> Callable[[Callback], Callback]: 87 | def bits_handler_decorator(func: Callback) -> Callback: 88 | BITS_HANDLERS[bits_mod] = func 89 | return func 90 | return bits_handler_decorator 91 | 92 | 93 | def add_alias(cmd: str, *aliases: str) -> None: 94 | for alias in aliases: 95 | COMMANDS[alias] = COMMANDS[cmd] 96 | SECRET_CMDS.add(alias) 97 | 98 | 99 | def periodic_handler(*, seconds: int) -> Callable[[Callback], Callback]: 100 | def periodic_handler_decorator(func: Callback) -> Callback: 101 | PERIODIC_HANDLERS.append((seconds, func)) 102 | return func 103 | return periodic_handler_decorator 104 | 105 | 106 | def get_handler(msg: str) -> tuple[Callback, Message] | None: 107 | parsed = Message.parse(msg) 108 | if parsed: 109 | if 'custom-reward-id' in parsed.info: 110 | if parsed.info['custom-reward-id'] in POINTS_HANDLERS: 111 | return POINTS_HANDLERS[parsed.info['custom-reward-id']], parsed 112 | else: 113 | return None 114 | 115 | if 'bits' in parsed.info: 116 | bits_n = int(parsed.info['bits']) 117 | if bits_n % 100 in BITS_HANDLERS: 118 | return BITS_HANDLERS[bits_n % 100], parsed 119 | 120 | cmd_match = COMMAND_RE.match(parsed.msg) 121 | if cmd_match: 122 | command = f'!{cmd_match["cmd"].lstrip("!").lower()}' 123 | if command in COMMANDS: 124 | return COMMANDS[command], parsed 125 | 126 | for pattern, handler in MSG_HANDLERS: 127 | match = pattern.match(parsed.msg) 128 | if match: 129 | return handler, parsed 130 | 131 | return None 132 | 133 | 134 | def _import_plugins() -> None: 135 | mod_infos = pkgutil.walk_packages(plugins.__path__, f'{plugins.__name__}.') 136 | for _, name, _ in mod_infos: 137 | __import__(name, fromlist=['_trash']) 138 | 139 | 140 | _import_plugins() 141 | 142 | 143 | # make this always last so that help is implemented properly 144 | @handle_message(r'!+\w') 145 | async def cmd_help(config: Config, msg: Message) -> str: 146 | possible_cmds = COMMANDS.keys() - SECRET_CMDS 147 | possible_cmds.difference_update(SECRET_CMDS) 148 | commands = ['!help'] + sorted(possible_cmds) 149 | 150 | cmd = msg.msg.split()[0] 151 | if cmd.startswith(('!help', '!halp')): 152 | msg_s = f' possible commands: {", ".join(commands)}' 153 | else: 154 | msg_s = f'unknown command ({esc(cmd)}).' 155 | suggestions = difflib.get_close_matches(cmd, commands, cutoff=0.7) 156 | if suggestions: 157 | msg_s += f' did you mean: {", ".join(suggestions)}?' 158 | else: 159 | msg_s += f' possible commands: {", ".join(commands)}' 160 | return format_msg(msg, msg_s) 161 | -------------------------------------------------------------------------------- /bot/plugins/simple.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | 5 | from bot.config import Config 6 | from bot.data import add_alias 7 | from bot.data import command 8 | from bot.data import format_msg 9 | from bot.message import Message 10 | 11 | 12 | _TEXT_COMMANDS: tuple[tuple[str, str], ...] = ( 13 | ( 14 | '!bot', 15 | 'I wrote the bot! https://github.com/anthonywritescode/twitch-chat-bot', # noqa: E501 16 | ), 17 | ( 18 | '!discord', 19 | 'We do have Dicsord, you are welcome to join: ' 20 | 'https://discord.gg/xDKGPaW', 21 | ), 22 | ( 23 | '!distro', 24 | 'awcActuallyWindows Windows 11 with Ubuntu 24.04 LTS virtual machine, ' 25 | 'more info here: https://www.youtube.com/watch?v=8KdAqlESQJo', 26 | ), 27 | ( 28 | '!donate', 29 | "donations are appreciated but not necessary -- if you'd like to " 30 | 'donate, you can donate at https://streamlabs.com/anthonywritescode', 31 | ), 32 | ( 33 | '!emotes', 34 | 'awcBongo awcUp awcDown awcCarpet awcBonk awcHug awcPaint awc7 ' 35 | 'awcHide awcFacepalm awcPythonk awcHelloHello awcPreCommit awcBabi ' 36 | 'awcNoodle0 awcNoodle1 awcNoodle2 awcKeebL awcKeebR ' 37 | 'awcActuallyWindows awcFLogo awcDumpsterFire', 38 | ), 39 | ( 40 | '!github', 41 | "anthony's github is https://github.com/asottile -- stream github is " 42 | 'https://github.com/anthonywritescode', 43 | ), 44 | ('!job', 'lmao. streamer? youtuber? ceo?'), 45 | ( 46 | '!keyboard', 47 | 'either ' 48 | '(normal) code v3 87-key (cherry mx clears) ' 49 | '(contributed by PhillipWei): https://amzn.to/3jzmwh3 ' 50 | 'or ' 51 | '(split) awcKeebL awcKeebR kinesis freestyle pro (cherry mx reds) ' 52 | 'https://amzn.to/3jyN4PC (faq: https://youtu.be/DZgCUWf9DZM )', 53 | ), 54 | ('!lurk', 'thanks for lurking, {user}!'), 55 | ('!ohai', 'ohai, {user}!'), 56 | ('!playlist', 'HearWeGo: https://www.youtube.com/playlist?list=PL44UysF4ZQ23B_ITIqM8Fqt1UXgsA9yD6'), # noqa: E501 57 | ( 58 | '!readme', 59 | 'GitHub recently posted a blog post about me: ' 60 | 'https://github.com/readme/stories/anthony-sottile', 61 | ), 62 | ( 63 | '!support', 64 | 'Here are the great ways to support my content: ' 65 | 'https://github.com/asottile/asottile/blob/HEAD/supporting.md', 66 | ), 67 | ('!twitter', 'https://twitter.com/codewithanthony'), 68 | ('!unlurk', 'welcome back, {user}!'), 69 | ( 70 | '!youtube', 71 | 'https://youtube.com/anthonywritescode -- ' 72 | 'stream vods: https://youtube.com/@anthonywritescode-vods', 73 | ), 74 | ) 75 | 76 | _SECRET_COMMANDS = ( 77 | ( 78 | '!aoc', 79 | 'advent of code is a series of puzzles which come out daily as an ' 80 | 'advent calendar in december -- for more information watch this ' 81 | 'wideo: https://youtu.be/CZZLCeRya74', 82 | ), 83 | ('!bluething', 'it is a fidget toy: https://amzn.to/35PmPQr'), 84 | ( 85 | '!book', 86 | "i don't read good", 87 | ), 88 | ('!chair', 'https://amzn.to/3zMzdPu'), 89 | ( 90 | '!copilot', 91 | 'Quick TLDR of my thoughts on Github copilot: ' 92 | 'https://clips.twitch.tv/AntediluvianCloudyDotterelSquadGoals-EnFRoJsDEnEF_IjI', # noqa: E501 93 | ), 94 | ( 95 | '!deadsnakes', 96 | 'I maintain deadsnakes! I backport and forward port pythons: ' 97 | 'https://github.com/deadsnakes -- ' 98 | 'see also https://youtu.be/Xe40amojaXE', 99 | ), 100 | ( 101 | '!flake8', 102 | 'I am the current primary maintainer of flake8! ' 103 | 'https://github.com/pycqa/flake8', 104 | ), 105 | ('!homeland', 'WE WILL PROTECT OUR HOMELAND!'), 106 | ( 107 | '!ikea', 108 | 'They\'re the "gladelig" set in blue: https://www.ikea.com/us/en/search/?q=gladelig&filters=f-colors%3A10007', # noqa: E501 109 | ), 110 | ( 111 | '!keyboard2', 112 | 'this is my second mechanical keyboard: ' 113 | 'https://i.fluffy.cc/CDtRzWX1JZTbqzKswHrZsF7HPX2zfLL1.png ' 114 | 'here is more info: https://youtu.be/rBngGyWCV-4', 115 | ), 116 | ( 117 | '!keyboard3', 118 | 'this is my stream deck keyboard (cherry mx black silent): ' 119 | 'https://keeb.io/products/bdn9-3x3-9-key-macropad-rotary-encoder-support ' # noqa: E501 120 | 'here is more info: https://www.youtube.com/watch?v=p2TyRIAxR48', 121 | ), 122 | ('!letsgo', 'ANYHONY CAN WE GET A LETS GO'), 123 | ('!levelup', 'https://i.imgur.com/Uoq5vGx.gif'), 124 | ( 125 | '!overlay', 126 | 'https://github.com/anthonywritescode/data-url-twitch-overlays', 127 | ), 128 | ( 129 | '!pokemon', 130 | 'I am soft resetting for shiny pokemon on a real switch using a ' 131 | 'microcontroller and computer vision -- ' 132 | 'see https://github.com/asottile/nintendo-microcontrollers ' 133 | 'or https://www.youtube.com/playlist?list=PLWBKAf81pmOYZoIyNPAnR7i56KV1JaRr0', # noqa: E501 134 | ), 135 | ( 136 | '!pre-commit', 137 | 'I created pre-commit! https://pre-commit.com and ' 138 | 'https://pre-commit.ci', 139 | ), 140 | ('!precommit', "it's spelled !pre-commit awcBongo"), 141 | ( 142 | '!pytest', 143 | 'yep, I am one of the pytest core devs ' 144 | 'https://github.com/pytest-dev/pytest', 145 | ), 146 | ( 147 | '!question', 148 | '"udp your questions, don\'t tcp your questions" - marsha_socks', 149 | ), 150 | ( 151 | '!rebase', 152 | 'https://clips.twitch.tv/HonestCrowdedGalagoStoneLightning-khp2n3Fqvo0Wdpno', # noqa: E501 153 | ), 154 | ( 155 | '!schedule', 156 | 'Monday evenings and Saturday at noon (EST) - ' 157 | 'Check !twitter and !dicsord for more, or see the google calendar ' 158 | 'link below the stream video.', 159 | ), 160 | ('!speechless', 'Good code changed like a ghost.Garbage.'), 161 | ('!tox', 'yep, I am a tox core dev https://github.com/tox-dev/tox'), 162 | ( 163 | '!vods', 164 | 'yep, vods are no longer on twitch due to DMCA shenanigans -- ' 165 | 'but you can find them on youtube! ' 166 | 'https://youtube.com/@anthonywritescode-vods (plz subscribe kthxbai)', 167 | ), 168 | ('!water', 'DRINK WATER, BITCH'), 169 | ( 170 | '!wm', 171 | 'the anthony window manager ' 172 | 'https://clips.twitch.tv/RefinedFunnyRavenFailFish', 173 | ), 174 | ) 175 | 176 | 177 | async def _generic_msg(config: Config, msg: Message, *, s: str) -> str: 178 | return format_msg(msg, s) 179 | 180 | 181 | for _cmd, _msg in _TEXT_COMMANDS: 182 | command(_cmd)(functools.partial(_generic_msg, s=_msg)) 183 | for _cmd, _msg in _SECRET_COMMANDS: 184 | command(_cmd, secret=True)(functools.partial(_generic_msg, s=_msg)) 185 | 186 | 187 | _ALIASES: tuple[tuple[str, tuple[str, ...]], ...] = ( 188 | ('!bluething', ('!blueball',)), 189 | ('!discord', ('!dicsord',)), 190 | ('!distro', ('!linux', '!os', '!ubuntu', '!vm', '!windows')), 191 | ('!emotes', ('!emoji', '!emote')), 192 | ('!job', ('!jorb',)), 193 | ('!keyboard', ('!keyboard1',)), 194 | ('!question', ('!ask', '!questions', '!tcp', '!udp')), 195 | ('!readme', ('!reamde',)), 196 | ('!speechless', ('!ghost',)), 197 | ('!vods', ('!bods',)), 198 | ('!youtube', ('!yt',)), 199 | ) 200 | for _alias_name, _aliases in _ALIASES: 201 | add_alias(_alias_name, *_aliases) 202 | -------------------------------------------------------------------------------- /bot/plugins/babi_theme.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import io 5 | import json 6 | import os.path 7 | import plistlib 8 | import re 9 | import uuid 10 | from typing import Any 11 | 12 | import aiohttp 13 | import cson 14 | import defusedxml.ElementTree 15 | 16 | from bot.config import Config 17 | from bot.data import channel_points_handler 18 | from bot.data import command 19 | from bot.data import esc 20 | from bot.data import format_msg 21 | from bot.message import Message 22 | 23 | ALLOWED_URL_PREFIXES = ( 24 | 'https://gist.github.com/', 25 | 'https://gist.githubusercontent.com/', 26 | 'https://github.com/', 27 | 'https://raw.githubusercontent.com/', 28 | ) 29 | 30 | COMMENT_TOKEN = re.compile(br'(\\\\|\\"|"|//|\n)') 31 | COMMA_TOKEN = re.compile(br'(\\\\|\\"|"|\]|\})') 32 | TRAILING_COMMA = re.compile(br',(\s*)$') 33 | 34 | THEME_DIR = os.path.abspath('.babi-themes') 35 | 36 | 37 | def _remove_comments(s: bytes) -> io.BytesIO: 38 | bio = io.BytesIO() 39 | 40 | idx = 0 41 | in_string = False 42 | in_comment = False 43 | 44 | match = COMMENT_TOKEN.search(s, idx) 45 | while match: 46 | if not in_comment: 47 | bio.write(s[idx:match.start()]) 48 | 49 | tok = match[0] 50 | if not in_comment and tok == b'"': 51 | in_string = not in_string 52 | elif in_comment and tok == b'\n': 53 | in_comment = False 54 | elif not in_string and tok == b'//': 55 | in_comment = True 56 | 57 | if not in_comment: 58 | bio.write(tok) 59 | 60 | idx = match.end() 61 | match = COMMENT_TOKEN.search(s, idx) 62 | bio.write(s[idx:]) 63 | 64 | return bio 65 | 66 | 67 | def _remove_trailing_commas(s: bytes) -> io.BytesIO: 68 | bio = io.BytesIO() 69 | 70 | idx = 0 71 | in_string = False 72 | 73 | match = COMMA_TOKEN.search(s, idx) 74 | while match: 75 | tok = match[0] 76 | if tok == b'"': 77 | in_string = not in_string 78 | bio.write(s[idx:match.start()]) 79 | bio.write(tok) 80 | elif in_string: 81 | bio.write(s[idx:match.start()]) 82 | bio.write(tok) 83 | elif tok in b']}': 84 | bio.write(TRAILING_COMMA.sub(br'\1', s[idx:match.start()])) 85 | bio.write(tok) 86 | else: 87 | bio.write(s[idx:match.start()]) 88 | bio.write(tok) 89 | 90 | idx = match.end() 91 | match = COMMA_TOKEN.search(s, idx) 92 | bio.write(s[idx:]) 93 | 94 | return bio 95 | 96 | 97 | def json_with_comments(s: bytes) -> Any: 98 | bio = _remove_comments(s) 99 | bio = _remove_trailing_commas(bio.getvalue()) 100 | 101 | bio.seek(0) 102 | return json.load(bio) 103 | 104 | 105 | def safe_ish_plist_loads(s: bytes) -> Any: 106 | # try and parse it using `defusedxml` first to make sure it's "safe" 107 | defusedxml.ElementTree.fromstring(s) 108 | return plistlib.loads(s) 109 | 110 | 111 | STRATEGIES = (json.loads, safe_ish_plist_loads, cson.loads, json_with_comments) 112 | 113 | 114 | def _validate_color(color: Any) -> None: 115 | if not isinstance(color, str): 116 | raise TypeError 117 | 118 | if color in {'black', 'white'}: 119 | return 120 | if not color.startswith('#'): 121 | raise ValueError 122 | 123 | # raises ValueError if incorrect 124 | int(color[1:], 16) 125 | 126 | 127 | def _validate_theme(theme: Any) -> None: 128 | if ( 129 | not isinstance(theme, dict) or 130 | not isinstance(theme.get('colors', {}), dict) or 131 | not isinstance(theme.get('tokenColors', []), list) or 132 | not isinstance(theme.get('settings', []), list) 133 | ): 134 | raise TypeError 135 | 136 | colors_dct = theme.get('colors', {}) 137 | for key in ( 138 | 'background', 139 | 'foreground', 140 | 'editor.foreground', 141 | 'editor.background', 142 | ): 143 | if key in colors_dct: 144 | _validate_color(colors_dct[key]) 145 | 146 | for rule in theme.get('tokenColors', []) + theme.get('settings', []): 147 | if not isinstance(rule, dict): 148 | raise TypeError 149 | for key in ('background', 'foreground'): 150 | if key in rule: 151 | _validate_color(rule[key]) 152 | 153 | 154 | class ThemeError(ValueError): 155 | pass 156 | 157 | 158 | async def _load_theme(url: str) -> dict[str, Any]: 159 | if not url.startswith(ALLOWED_URL_PREFIXES): 160 | raise ThemeError('error: url must be from github!') 161 | 162 | if '/blob/' in url: 163 | url = url.replace('/blob/', '/raw/') 164 | 165 | try: 166 | async with aiohttp.ClientSession( 167 | raise_for_status=True, 168 | read_timeout=2, 169 | ) as session: 170 | async with session.get(url) as resp: 171 | data = await resp.read() 172 | except aiohttp.ClientError: 173 | raise ThemeError('error: could not download url!') 174 | 175 | for strategy in STRATEGIES: 176 | try: 177 | loaded = strategy(data) 178 | except Exception: 179 | pass 180 | else: 181 | break 182 | else: 183 | raise ThemeError('error: could not parse theme!') 184 | 185 | try: 186 | _validate_theme(loaded) 187 | except (TypeError, ValueError): 188 | raise ThemeError('error: malformed theme!') 189 | 190 | return loaded 191 | 192 | 193 | @channel_points_handler('5861c27a-ae1f-4b8e-af03-88f12dd7d23a') 194 | async def change_theme(config: Config, msg: Message) -> str: 195 | url = msg.msg.strip() 196 | 197 | try: 198 | loaded = await _load_theme(url) 199 | except ThemeError as e: 200 | return format_msg(msg, str(e)) 201 | 202 | loaded['user'] = msg.display_name 203 | loaded['url'] = url 204 | 205 | os.makedirs(THEME_DIR, exist_ok=True) 206 | theme_file = f'{msg.display_name}-{uuid.uuid4()}.json' 207 | theme_file = os.path.join(THEME_DIR, theme_file) 208 | with open(theme_file, 'w') as f: 209 | json.dump(loaded, f) 210 | 211 | themedir = os.path.expanduser('~/.config/babi') 212 | os.makedirs(themedir, exist_ok=True) 213 | 214 | dest = os.path.join(themedir, 'theme.json') 215 | proc = await asyncio.create_subprocess_exec('ln', '-sf', theme_file, dest) 216 | await proc.communicate() 217 | assert proc.returncode == 0 218 | 219 | proc = await asyncio.create_subprocess_exec('pkill', '-USR1', 'babi') 220 | await proc.communicate() 221 | # ignore the return code, if there are no editors running it'll be `1` 222 | # assert proc.returncode == 0 223 | 224 | return format_msg(msg, 'theme updated!') 225 | 226 | 227 | @command('!theme') 228 | async def command_theme(config: Config, msg: Message) -> str: 229 | theme_file = os.path.expanduser('~/.config/babi/theme.json') 230 | if not os.path.exists(theme_file): 231 | return format_msg( 232 | msg, 233 | 'awcBabi this is vs dark plus in !babi with one modification to ' 234 | 'highlight ini headers: ' 235 | 'https://github.com/asottile/babi#setting-up-syntax-highlighting', 236 | ) 237 | 238 | with open(theme_file) as f: 239 | contents = json.load(f) 240 | 241 | try: 242 | name = contents.get('name', '(unknown)') 243 | user = contents['user'] 244 | url = contents['url'] 245 | except KeyError: 246 | return format_msg(msg, "awcBabi I don't know what this theme is!?") 247 | else: 248 | return format_msg( 249 | msg, 250 | f'awcBabi this theme was set by {esc(user)} using channel points! ' 251 | f'it is called {esc(name)!r} and can be download from {esc(url)}', 252 | ) 253 | 254 | 255 | @command('!themevalidate', secret=True) 256 | async def command_themevalidate(config: Config, msg: Message) -> str: 257 | _, _, url = msg.msg.partition(' ') 258 | try: 259 | await _load_theme(url.strip()) 260 | except ThemeError as e: 261 | return format_msg(msg, str(e)) 262 | else: 263 | return format_msg(msg, 'theme is ok!') 264 | -------------------------------------------------------------------------------- /bot/plugins/chatrank.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections 4 | import datetime 5 | import functools 6 | import json 7 | import os 8 | import re 9 | import urllib.request 10 | from collections import Counter 11 | from collections.abc import Mapping 12 | from collections.abc import Sequence 13 | from re import Pattern 14 | from typing import Any 15 | 16 | from bot.config import Config 17 | from bot.data import command 18 | from bot.data import esc 19 | from bot.data import format_msg 20 | from bot.message import Message 21 | from bot.ranking import tied_rank 22 | 23 | CHAT_ALIASES = { 24 | 'jast_lucy': 'snipsyfox', 25 | 'kevinsjoberg': 'hovsater', 26 | 'kevinwritescode': 'hovsater', 27 | 'kmjao': 'hovsater', 28 | 'makayla_fox': 'marsha_socks', 29 | 'metabytez': 'mrmetabytes', 30 | 'naughtmeta': 'mrmetabytes', 31 | 'theqexat': 'lesbianmonad', 32 | '케그자트': 'lesbianmonad', 33 | } 34 | CHAT_LOG_RE = re.compile( 35 | r'^\[[^]]+\][^<*]*(<(?P[^>]+)>|\* (?P[^ ]+))', 36 | ) 37 | BONKER_RE = re.compile(r'^\[[^]]+\][^<*]*<(?P[^>]+)> !bonk\b') 38 | BONKED_RE = re.compile(r'^\[[^]]+\][^<*]*<[^>]+> !bonk @?(?P\w+)') 39 | 40 | 41 | def _alias(user: str) -> str: 42 | return CHAT_ALIASES.get(user, user) 43 | 44 | 45 | @functools.cache 46 | def _counts_per_file(filename: str, reg: Pattern[str]) -> Mapping[str, int]: 47 | counts: Counter[str] = collections.Counter() 48 | with open(filename, encoding='utf8') as f: 49 | for line in f: 50 | match = reg.match(line) 51 | if match is None: 52 | assert reg is not CHAT_LOG_RE 53 | continue 54 | user = match['chat_user'] or match['action_user'] 55 | assert user, line 56 | 57 | counts[_alias(user.lower())] += 1 58 | return counts 59 | 60 | 61 | def _chat_rank_counts(reg: Pattern[str]) -> Counter[str]: 62 | total: Counter[str] = collections.Counter() 63 | for filename in os.listdir('logs'): 64 | full_filename = os.path.join('logs', filename) 65 | if filename != f'{datetime.date.today()}.log': 66 | total.update(_counts_per_file(full_filename, reg)) 67 | else: 68 | # don't use the cached version for today's logs 69 | total.update(_counts_per_file.__wrapped__(full_filename, reg)) 70 | return total 71 | 72 | 73 | def _user_rank_by_line_type( 74 | username: str, reg: Pattern[str], 75 | ) -> tuple[int, int] | None: 76 | total = _chat_rank_counts(reg) 77 | target_username = username.lower() 78 | for rank, (count, users) in tied_rank(total.most_common()): 79 | for username, _ in users: 80 | if target_username == username: 81 | return rank, count 82 | else: 83 | return None 84 | 85 | 86 | def _top_n_rank_by_line_type(reg: Pattern[str], n: int = 10) -> list[str]: 87 | total = _chat_rank_counts(reg) 88 | user_list = [] 89 | for rank, (count, users) in tied_rank(total.most_common(n)): 90 | usernames = ', '.join(username for username, _ in users) 91 | user_list.append(f'{rank}. {usernames} ({count})') 92 | return user_list 93 | 94 | 95 | @functools.lru_cache(maxsize=1) 96 | def _log_start_date() -> str: 97 | logs_start = min(os.listdir('logs')) 98 | logs_start, _, _ = logs_start.partition('.') 99 | return logs_start 100 | 101 | 102 | @command('!chatrank') 103 | async def cmd_chatrank(config: Config, msg: Message) -> str: 104 | # TODO: handle display name 105 | user = msg.optional_user_arg.lower() 106 | ret = _user_rank_by_line_type(user, CHAT_LOG_RE) 107 | if ret is None: 108 | return format_msg(msg, f'user not found {esc(user)}') 109 | else: 110 | rank, n = ret 111 | return format_msg( 112 | msg, 113 | f'{esc(user)} is ranked #{rank} with {n} messages ' 114 | f'(since {_log_start_date()})', 115 | ) 116 | 117 | 118 | @command('!top10chat') 119 | async def cmd_top_10_chat(config: Config, msg: Message) -> str: 120 | top_10_s = ', '.join(_top_n_rank_by_line_type(CHAT_LOG_RE, n=10)) 121 | return format_msg(msg, f'{top_10_s} (since {_log_start_date()})') 122 | 123 | 124 | @command('!bonkrank', secret=True) 125 | async def cmd_bonkrank(config: Config, msg: Message) -> str: 126 | # TODO: handle display name 127 | user = msg.optional_user_arg.lower() 128 | ret = _user_rank_by_line_type(user, BONKER_RE) 129 | if ret is None: 130 | return format_msg(msg, f'user not found {esc(user)}') 131 | else: 132 | rank, n = ret 133 | return format_msg( 134 | msg, 135 | f'{esc(user)} is ranked #{rank}, has bonked others {n} times', 136 | ) 137 | 138 | 139 | @command('!top5bonkers', secret=True) 140 | async def cmd_top_5_bonkers(config: Config, msg: Message) -> str: 141 | top_5_s = ', '.join(_top_n_rank_by_line_type(BONKER_RE, n=5)) 142 | return format_msg(msg, top_5_s) 143 | 144 | 145 | @command('!bonkedrank', secret=True) 146 | async def cmd_bonkedrank(config: Config, msg: Message) -> str: 147 | # TODO: handle display name 148 | user = msg.optional_user_arg.lower() 149 | ret = _user_rank_by_line_type(user, BONKED_RE) 150 | if ret is None: 151 | return format_msg(msg, f'user not found {esc(user)}') 152 | else: 153 | rank, n = ret 154 | return format_msg( 155 | msg, 156 | f'{esc(user)} is ranked #{rank}, has been bonked {n} times', 157 | ) 158 | 159 | 160 | @command('!top5bonked', secret=True) 161 | async def cmd_top_5_bonked(config: Config, msg: Message) -> str: 162 | top_5_s = ', '.join(_top_n_rank_by_line_type(BONKED_RE, n=5)) 163 | return format_msg(msg, top_5_s) 164 | 165 | 166 | def lin_regr(x: Sequence[float], y: Sequence[float]) -> tuple[float, float]: 167 | sum_x = sum(x) 168 | sum_xx = sum(xi * xi for xi in x) 169 | sum_y = sum(y) 170 | sum_xy = sum(xi * yi for xi, yi in zip(x, y)) 171 | b = (sum_y * sum_xx - sum_x * sum_xy) / (len(x) * sum_xx - sum_x * sum_x) 172 | a = (sum_xy - b * sum_x) / sum_xx 173 | return a, b 174 | 175 | 176 | @command('!chatplot') 177 | async def cmd_chatplot(config: Config, msg: Message) -> str: 178 | # TODO: handle display name 179 | user_list = msg.optional_user_arg.lower().split() 180 | user_list = [_alias(user.lstrip('@')) for user in user_list] 181 | user_list = list(dict.fromkeys(user_list)) 182 | 183 | if len(user_list) > 2: 184 | return format_msg(msg, 'sorry, can only compare 2 users') 185 | 186 | min_date = datetime.date.fromisoformat(_log_start_date()) 187 | comp_users: dict[str, dict[str, list[int]]] 188 | comp_users = collections.defaultdict(lambda: {'x': [], 'y': []}) 189 | for filename in sorted(os.listdir('logs')): 190 | if filename == f'{datetime.date.today()}.log': 191 | continue 192 | 193 | filename_date = datetime.date.fromisoformat(filename.split('.')[0]) 194 | 195 | full_filename = os.path.join('logs', filename) 196 | counts = _counts_per_file(full_filename, CHAT_LOG_RE) 197 | for user in user_list: 198 | if counts[user]: 199 | comp_users[user]['x'].append((filename_date - min_date).days) 200 | comp_users[user]['y'].append(counts[user]) 201 | 202 | # create the datasets (scatter and trend line) for all users to compare 203 | PLOT_COLORS = ('#00a3ce', '#fab040') 204 | datasets: list[dict[str, Any]] = [] 205 | for user, color in zip(user_list, PLOT_COLORS): 206 | if len(comp_users[user]['x']) < 2: 207 | if len(user_list) > 1: 208 | return format_msg( 209 | msg, 210 | 'sorry, all users need at least 2 days of data', 211 | ) 212 | else: 213 | return format_msg( 214 | msg, 215 | f'sorry {esc(user)}, need at least 2 days of data', 216 | ) 217 | 218 | point_data = { 219 | 'label': f"{user}'s chats", 220 | 'borderColor': color, 221 | # add alpha to the point fill color 222 | 'backgroundColor': f'{color}69', 223 | 'data': [ 224 | {'x': x_i, 'y': y_i} 225 | for x_i, y_i in 226 | zip(comp_users[user]['x'], comp_users[user]['y']) 227 | if y_i 228 | ], 229 | } 230 | m, c = lin_regr(comp_users[user]['x'], comp_users[user]['y']) 231 | trend_data = { 232 | 'borderColor': color, 233 | 'type': 'line', 234 | 'fill': False, 235 | 'pointRadius': 0, 236 | 'data': [ 237 | { 238 | 'x': comp_users[user]['x'][0], 239 | 'y': m * comp_users[user]['x'][0] + c, 240 | }, 241 | { 242 | 'x': comp_users[user]['x'][-1], 243 | 'y': m * comp_users[user]['x'][-1] + c, 244 | }, 245 | ], 246 | } 247 | datasets.append(point_data) 248 | datasets.append(trend_data) 249 | 250 | # generate title checking if we are comparing users 251 | if len(user_list) > 1: 252 | title_user = "'s, ".join(user_list) 253 | title_user = f"{title_user}'s" 254 | else: 255 | title_user = f"{user_list[0]}'s" 256 | 257 | chart = { 258 | 'type': 'scatter', 259 | 'data': { 260 | 'datasets': datasets, 261 | }, 262 | 'options': { 263 | 'scales': { 264 | 'xAxes': [{'ticks': {'callback': 'CALLBACK'}}], 265 | 'yAxes': [{'ticks': {'beginAtZero': True, 'min': 0}}], 266 | }, 267 | 'title': { 268 | 'display': True, 269 | 'text': f'{title_user} chat in twitch.tv/{config.channel}', 270 | }, 271 | 'legend': { 272 | 'labels': {'filter': 'FILTER'}, 273 | }, 274 | }, 275 | } 276 | 277 | callback = ( 278 | 'x=>{' 279 | f'y=new Date({str(min_date)!r});' 280 | 'y.setDate(x+y.getDate());return y.toISOString().slice(0,10)' 281 | '}' 282 | ) 283 | # https://github.com/chartjs/Chart.js/issues/3189#issuecomment-528362213 284 | filter = ( 285 | '(legendItem, chartData)=>{' 286 | ' return (chartData.datasets[legendItem.datasetIndex].label);' 287 | '}' 288 | ) 289 | data = json.dumps(chart, separators=(',', ':')) 290 | data = data.replace('"CALLBACK"', callback) 291 | data = data.replace('"FILTER"', filter) 292 | 293 | post_data = {'chart': data} 294 | request = urllib.request.Request( 295 | 'https://quickchart.io/chart/create', 296 | method='POST', 297 | data=json.dumps(post_data).encode(), 298 | headers={'Content-Type': 'application/json'}, 299 | ) 300 | resp = urllib.request.urlopen(request) 301 | contents = json.load(resp) 302 | user_esc = [esc(user) for user in user_list] 303 | if len(user_list) > 1: 304 | return format_msg( 305 | msg, 306 | f'comparing {", ".join(user_esc)}: {contents["url"]}', 307 | ) 308 | else: 309 | return format_msg(msg, f'{esc(user_esc[0])}: {contents["url"]}') 310 | -------------------------------------------------------------------------------- /bot/plugins/vim_timer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections 4 | import datetime 5 | import os.path 6 | from collections import Counter 7 | 8 | import aiosqlite 9 | 10 | from bot.config import Config 11 | from bot.data import bits_handler 12 | from bot.data import command 13 | from bot.data import esc 14 | from bot.data import format_msg 15 | from bot.data import periodic_handler 16 | from bot.message import Message 17 | from bot.ranking import tied_rank 18 | from bot.util import check_call 19 | from bot.util import seconds_to_readable 20 | 21 | # periodic: per-second check to restore state 22 | # data: 23 | # - (datetime, user, bits) 24 | # - (end datetime) 25 | # - (enabled) 26 | # commands: 27 | # - bits handler 28 | # - !vimtimeleft 29 | # - !vimdisable 30 | # - !vimenable 31 | 32 | _VIM_BITS_TABLE = '''\ 33 | CREATE TABLE IF NOT EXISTS vim_bits ( 34 | user TEXT NOT NULL, 35 | bits INT NOT NULL, 36 | timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP 37 | ) 38 | ''' 39 | _VIM_BITS_DISABLED_TABLE = '''\ 40 | CREATE TABLE IF NOT EXISTS vim_bits_disabled ( 41 | user TEXT NOT NULL, 42 | bits INT NOT NULL, 43 | timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP 44 | ) 45 | ''' 46 | _VIM_TIME_LEFT_TABLE = '''\ 47 | CREATE TABLE IF NOT EXISTS vim_time_left ( 48 | timestamp TIMESTAMP NOT NULL 49 | ) 50 | ''' 51 | _VIM_ENABLED_TABLE = '''\ 52 | CREATE TABLE IF NOT EXISTS vim_enabled ( 53 | enabled INT NOT NULL 54 | ) 55 | ''' 56 | 57 | 58 | async def _ln_sf(dest: str, link: str) -> None: 59 | await check_call('ln', '-sf', dest, link) 60 | 61 | 62 | async def _set_symlink(*, should_be_vim: bool) -> bool: 63 | babi_path = os.path.expanduser('~/opt/venv/bin/babi') 64 | vim_path = os.path.expanduser('/usr/bin/vim') 65 | babi_bin = os.path.expanduser('~/bin/babi') 66 | 67 | path = os.path.realpath(babi_bin) 68 | 69 | if should_be_vim and path != vim_path: 70 | await _ln_sf(dest=vim_path, link=babi_bin) 71 | return False 72 | elif not should_be_vim and path != babi_path: 73 | await _ln_sf(dest=babi_path, link=babi_bin) 74 | return True 75 | else: 76 | return False 77 | 78 | 79 | async def ensure_vim_tables_exist(db: aiosqlite.Connection) -> None: 80 | await db.execute(_VIM_BITS_TABLE) 81 | await db.execute(_VIM_BITS_DISABLED_TABLE) 82 | await db.execute(_VIM_TIME_LEFT_TABLE) 83 | await db.execute(_VIM_ENABLED_TABLE) 84 | await db.commit() 85 | 86 | 87 | async def get_enabled(db: aiosqlite.Connection) -> bool: 88 | query = 'SELECT enabled FROM vim_enabled ORDER BY ROWID DESC LIMIT 1' 89 | async with db.execute(query) as cursor: 90 | ret = await cursor.fetchone() 91 | if ret is None: 92 | return True 93 | else: 94 | enabled, = ret 95 | return bool(enabled) 96 | 97 | 98 | async def get_time_left(db: aiosqlite.Connection) -> int: 99 | if not await get_enabled(db): 100 | return 0 101 | 102 | query = 'SELECT timestamp FROM vim_time_left ORDER BY ROWID DESC LIMIT 1' 103 | async with db.execute(query) as cursor: 104 | ret = await cursor.fetchone() 105 | if ret is None: 106 | return 0 107 | else: 108 | dt = datetime.datetime.fromisoformat(ret[0]) 109 | if dt < datetime.datetime.now(): 110 | return 0 111 | else: 112 | return (dt - datetime.datetime.now()).seconds 113 | 114 | 115 | def _bits_to_seconds(bits: int) -> int: 116 | return 60 * (100 + bits - 51) // 100 117 | 118 | 119 | async def add_time(db: aiosqlite.Connection, seconds: int) -> int: 120 | time_left = await get_time_left(db) 121 | time_left += seconds 122 | await db.execute( 123 | 'INSERT INTO vim_time_left VALUES (?)', 124 | (datetime.datetime.now() + datetime.timedelta(seconds=time_left),), 125 | ) 126 | return time_left 127 | 128 | 129 | async def add_bits(db: aiosqlite.Connection, user: str, bits: int) -> int: 130 | vim_bits_query = 'INSERT INTO vim_bits (user, bits) VALUES (?, ?)' 131 | await db.execute(vim_bits_query, (user, bits)) 132 | time_left = await add_time(db, _bits_to_seconds(bits)) 133 | await db.commit() 134 | return time_left 135 | 136 | 137 | async def disabled_seconds(db: aiosqlite.Connection) -> int: 138 | async with db.execute('SELECT bits FROM vim_bits_disabled') as cursor: 139 | rows = await cursor.fetchall() 140 | return sum(_bits_to_seconds(bits) for bits, in rows) 141 | 142 | 143 | async def add_bits_off(db: aiosqlite.Connection, user: str, bits: int) -> int: 144 | vim_bits_query = 'INSERT INTO vim_bits_disabled (user, bits) VALUES (?, ?)' 145 | await db.execute(vim_bits_query, (user, bits)) 146 | time_left = await disabled_seconds(db) 147 | await db.commit() 148 | return time_left 149 | 150 | 151 | @bits_handler(51) 152 | async def vim_bits_handler(config: Config, msg: Message) -> str: 153 | async with aiosqlite.connect('db.db') as db: 154 | await ensure_vim_tables_exist(db) 155 | enabled = await get_enabled(db) 156 | 157 | bits = int(msg.info['bits']) 158 | if enabled: 159 | # TODO: fix casing of names 160 | time_left = await add_bits(db, msg.name_key, bits) 161 | else: 162 | time_left = await add_bits_off(db, msg.name_key, bits) 163 | 164 | if enabled: 165 | await _set_symlink(should_be_vim=True) 166 | 167 | return format_msg( 168 | msg, 169 | f'MOAR VIM: {seconds_to_readable(time_left)} remaining', 170 | ) 171 | else: 172 | return format_msg( 173 | msg, 174 | f'vim is currently disabled ' 175 | f'{seconds_to_readable(time_left)} banked', 176 | ) 177 | 178 | 179 | async def _get_user_vim_bits( 180 | db: aiosqlite.Connection, 181 | ) -> Counter[str]: 182 | vim_bits_query = 'SELECT user, SUM(bits) FROM vim_bits GROUP BY user' 183 | async with db.execute(vim_bits_query) as cursor: 184 | rows = await cursor.fetchall() 185 | bits_counts = collections.Counter(dict(rows)) 186 | return bits_counts 187 | 188 | 189 | async def _user_rank_by_bits( 190 | username: str, db: aiosqlite.Connection, 191 | ) -> tuple[int, int] | None: 192 | total = await _get_user_vim_bits(db) 193 | target_username = username.lower() 194 | for rank, (count, users) in tied_rank(total.most_common()): 195 | for username, _ in users: 196 | if target_username == username.lower(): 197 | return rank, count 198 | else: 199 | return None 200 | 201 | 202 | async def _top_n_rank_by_bits( 203 | db: aiosqlite.Connection, n: int = 5, 204 | ) -> list[str]: 205 | total = await _get_user_vim_bits(db) 206 | user_list = [] 207 | for rank, (count, users) in tied_rank(total.most_common(n)): 208 | usernames = ', '.join(username for username, _ in users) 209 | user_list.append(f'{rank}. {usernames} ({count})') 210 | return user_list 211 | 212 | 213 | @command('!top5vimbits', '!topvimbits', secret=True) 214 | async def cmd_topvimbits(config: Config, msg: Message) -> str: 215 | async with aiosqlite.connect('db.db') as db: 216 | await ensure_vim_tables_exist(db) 217 | top_10_s = ', '.join(await _top_n_rank_by_bits(db, n=5)) 218 | return format_msg(msg, f'{top_10_s}') 219 | 220 | 221 | @command('!vimbitsrank', secret=True) 222 | async def cmd_vimbitsrank(config: Config, msg: Message) -> str: 223 | # TODO: handle display name properly 224 | user = msg.optional_user_arg.lower() 225 | async with aiosqlite.connect('db.db') as db: 226 | ret = await _user_rank_by_bits(user, db) 227 | if ret is None: 228 | return format_msg(msg, f'user not found {esc(user)}') 229 | else: 230 | rank, n = ret 231 | return format_msg( 232 | msg, 233 | f'{esc(user)} is ranked #{rank} with {n} vim bits', 234 | ) 235 | 236 | 237 | @command('!vimtimeleft', secret=True) 238 | async def cmd_vimtimeleft(config: Config, msg: Message) -> str: 239 | async with aiosqlite.connect('db.db') as db: 240 | await ensure_vim_tables_exist(db) 241 | if not await get_enabled(db): 242 | return format_msg(msg, 'vim is currently disabled') 243 | 244 | time_left = await get_time_left(db) 245 | if time_left == 0: 246 | return format_msg(msg, 'not currently using vim') 247 | else: 248 | return format_msg( 249 | msg, 250 | f'vim time remaining: {seconds_to_readable(time_left)}', 251 | ) 252 | 253 | 254 | @command('!disablevim', secret=True) 255 | async def cmd_disablevim(config: Config, msg: Message) -> str: 256 | if not msg.is_moderator and msg.name_key != config.channel: 257 | return format_msg(msg, 'https://youtu.be/RfiQYRn7fBg') 258 | 259 | async with aiosqlite.connect('db.db') as db: 260 | await ensure_vim_tables_exist(db) 261 | 262 | await db.execute('INSERT INTO vim_enabled VALUES (0)') 263 | await db.commit() 264 | 265 | return format_msg(msg, 'vim has been disabled') 266 | 267 | 268 | @command('!enablevim', secret=True) 269 | async def cmd_enablevim(config: Config, msg: Message) -> str: 270 | if not msg.is_moderator and msg.name_key != config.channel: 271 | return format_msg(msg, 'https://youtu.be/RfiQYRn7fBg') 272 | 273 | async with aiosqlite.connect('db.db') as db: 274 | await ensure_vim_tables_exist(db) 275 | 276 | await db.execute('INSERT INTO vim_enabled VALUES (1)') 277 | move_query = 'INSERT INTO vim_bits SELECT * FROM vim_bits_disabled' 278 | await db.execute(move_query) 279 | time_left = await add_time(db, await disabled_seconds(db)) 280 | await db.execute('DELETE FROM vim_bits_disabled') 281 | await db.commit() 282 | 283 | if time_left == 0: 284 | return format_msg(msg, 'vim has been enabled') 285 | else: 286 | await _set_symlink(should_be_vim=True) 287 | return format_msg( 288 | msg, 289 | f'vim has been enabled: ' 290 | f'time remaining {seconds_to_readable(time_left)}', 291 | ) 292 | 293 | 294 | @command( 295 | '!editor', 296 | '!babi', '!nano', '!vim', '!emacs', '!vscode', '!wheredobabiscomefrom', 297 | ) 298 | async def cmd_editor(config: Config, msg: Message) -> str: 299 | async with aiosqlite.connect('db.db') as db: 300 | await ensure_vim_tables_exist(db) 301 | if await get_time_left(db): 302 | return format_msg( 303 | msg, 304 | 'I am currently being forced to use vim by viewers. ' 305 | 'awcBabi I normally use my text editor I made, called babi! ' 306 | 'https://github.com/asottile/babi more info in this video: ' 307 | 'https://www.youtube.com/watch?v=WyR1hAGmR3g', 308 | ) 309 | else: 310 | return format_msg( 311 | msg, 312 | 'awcBabi this is my text editor I made, called babi! ' 313 | 'https://github.com/asottile/babi more info in this video: ' 314 | 'https://www.youtube.com/watch?v=WyR1hAGmR3g', 315 | ) 316 | 317 | 318 | @periodic_handler(seconds=5) 319 | async def vim_normalize_state(config: Config, msg: Message) -> str | None: 320 | async with aiosqlite.connect('db.db') as db: 321 | await ensure_vim_tables_exist(db) 322 | time_left = await get_time_left(db) 323 | 324 | cleared_vim = await _set_symlink(should_be_vim=time_left > 0) 325 | if cleared_vim: 326 | return format_msg(msg, 'vim no more! you are free!') 327 | else: 328 | return None 329 | -------------------------------------------------------------------------------- /bot/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import asyncio.subprocess 5 | import contextlib 6 | import datetime 7 | import functools 8 | import json 9 | import os.path 10 | import re 11 | import signal 12 | import sys 13 | import traceback 14 | from collections.abc import AsyncGenerator 15 | 16 | from bot.badges import badges_images 17 | from bot.badges import badges_plain_text 18 | from bot.badges import download_all_badges 19 | from bot.badges import parse_badges 20 | from bot.config import Config 21 | from bot.data import Callback 22 | from bot.data import get_fake_msg 23 | from bot.data import get_handler 24 | from bot.data import PERIODIC_HANDLERS 25 | from bot.data import PRIVMSG 26 | from bot.message import Message 27 | from bot.parse_message import colorize 28 | from bot.parse_message import parse_message_parts 29 | from bot.parse_message import parsed_to_terminology 30 | 31 | # TODO: allow host / port to be configurable 32 | HOST = 'irc.chat.twitch.tv' 33 | PORT = 6697 34 | 35 | SEND_MSG_RE = re.compile('^PRIVMSG #[^ ]+ :(?P[^\r]+)') 36 | 37 | 38 | async def send( 39 | writer: asyncio.StreamWriter, 40 | msg: str, 41 | *, 42 | quiet: bool = False, 43 | ) -> None: 44 | if not quiet: 45 | print(f'< {msg}', end='', flush=True, file=sys.stderr) 46 | writer.write(msg.encode()) 47 | return await writer.drain() 48 | 49 | 50 | async def recv( 51 | reader: asyncio.StreamReader, 52 | *, 53 | quiet: bool = False, 54 | ) -> bytes: 55 | data = await reader.readline() 56 | if not quiet: 57 | sys.stderr.buffer.write(b'> ') 58 | sys.stderr.buffer.write(data) 59 | sys.stderr.flush() 60 | return data 61 | 62 | 63 | def _shutdown( 64 | writer: asyncio.StreamWriter, 65 | loop: asyncio.AbstractEventLoop, 66 | ) -> None: 67 | print('bye!') 68 | 69 | if writer: 70 | writer.close() 71 | loop.create_task(writer.wait_closed()) 72 | 73 | 74 | async def connect( 75 | config: Config, 76 | *, 77 | quiet: bool, 78 | ) -> tuple[AsyncGenerator[bytes], asyncio.StreamWriter]: 79 | async def _new_conn() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: 80 | reader, writer = await asyncio.open_connection(HOST, PORT, ssl=True) 81 | 82 | loop = asyncio.get_event_loop() 83 | shutdown_cb = functools.partial(_shutdown, writer, loop) 84 | try: 85 | loop.add_signal_handler(signal.SIGINT, shutdown_cb) 86 | except NotImplementedError: 87 | # Doh... Windows... 88 | signal.signal(signal.SIGINT, lambda *_: shutdown_cb()) 89 | 90 | await send(writer, 'CAP REQ :twitch.tv/tags\r\n', quiet=quiet) 91 | await send(writer, f'PASS {config.oauth_token}\r\n', quiet=True) 92 | await send(writer, f'NICK {config.username}\r\n', quiet=quiet) 93 | await send(writer, f'JOIN #{config.channel}\r\n', quiet=quiet) 94 | 95 | return reader, writer 96 | 97 | reader, writer = await _new_conn() 98 | 99 | async def next_line() -> AsyncGenerator[bytes]: 100 | nonlocal reader, writer 101 | 102 | while not writer.is_closing(): 103 | data = await recv(reader, quiet=quiet) 104 | if not data: 105 | if writer.is_closing(): 106 | return 107 | else: 108 | print('!!!reconnect!!!') 109 | reader, writer = await _new_conn() 110 | continue 111 | 112 | yield data 113 | 114 | return next_line(), writer 115 | 116 | 117 | # TODO: !tags, only allowed by stream admin / mods???? 118 | 119 | def dt_str() -> str: 120 | dt_now = datetime.datetime.now() 121 | return f'[{dt_now.hour:02}:{dt_now.minute:02}]' 122 | 123 | 124 | UNCOLOR_RE = re.compile(r'\033\[[^m]*m') 125 | 126 | 127 | class LogWriter: 128 | def __init__(self) -> None: 129 | self.date = str(datetime.date.today()) 130 | 131 | def write_message(self, msg: str) -> None: 132 | uncolored_msg = UNCOLOR_RE.sub('', msg) 133 | os.makedirs('logs', exist_ok=True) 134 | log = os.path.join('logs', f'{self.date}.log') 135 | with open(log, 'a+', encoding='UTF-8') as f: 136 | f.write(f'{uncolored_msg}\n') 137 | 138 | 139 | def get_printed_output(config: Config, res: str) -> str | None: 140 | send_match = SEND_MSG_RE.match(res) 141 | if send_match: 142 | color = '\033[1m\033[3m\033[38;5;21m' 143 | return ( 144 | f'{dt_str()}' 145 | f'<{color}{config.username}\033[m> ' 146 | f'{send_match[1]}' 147 | ) 148 | else: 149 | return None 150 | 151 | 152 | async def handle_response( 153 | config: Config, 154 | msg: Message, 155 | handler: Callback, 156 | writer: asyncio.StreamWriter, 157 | log_writer: LogWriter, 158 | *, 159 | quiet: bool, 160 | ) -> None: 161 | try: 162 | res = await handler(config, msg) 163 | except Exception as e: 164 | traceback.print_exc() 165 | res = PRIVMSG.format( 166 | channel=config.channel, 167 | msg=f'*** unhandled {type(e).__name__} -- see logs', 168 | ) 169 | if res is not None: 170 | printed_output = get_printed_output(config, res) 171 | if printed_output is not None: 172 | print(printed_output) 173 | log_writer.write_message(printed_output) 174 | await send(writer, res, quiet=quiet) 175 | 176 | 177 | def _start_periodic( 178 | config: Config, 179 | writer: asyncio.StreamWriter, 180 | log_writer: LogWriter, 181 | *, 182 | quiet: bool, 183 | ) -> None: 184 | async def periodic(seconds: int, func: Callback) -> None: 185 | msg = Message( 186 | msg='placeholder', 187 | is_me=False, 188 | channel=config.channel, 189 | info={'display-name': config.username}, 190 | ) 191 | while True: 192 | await asyncio.sleep(seconds) 193 | await handle_response( 194 | config, msg, func, writer, log_writer, quiet=quiet, 195 | ) 196 | 197 | loop = asyncio.get_event_loop() 198 | for seconds, func in PERIODIC_HANDLERS: 199 | loop.create_task(periodic(seconds, func)) 200 | 201 | 202 | async def get_printed_input( 203 | config: Config, 204 | msg: str, 205 | *, 206 | images: bool, 207 | ) -> tuple[str, str] | None: 208 | parsed = Message.parse(msg) 209 | if parsed: 210 | r, g, b = parsed.color 211 | color_start = f'\033[1m\033[38;2;{r};{g};{b}m' 212 | 213 | badges_s = badges_plain_text(parsed.badges) 214 | if images: 215 | # TODO: maybe combine into `Message`? 216 | badges = parse_badges(parsed.info['badges']) 217 | await download_all_badges( 218 | parse_badges(parsed.info['badges']), 219 | channel=config.channel, 220 | oauth_token=config.oauth_token_token, 221 | client_id=config.client_id, 222 | ) 223 | badges_s_images = badges_images(badges) 224 | else: 225 | badges_s_images = badges_s 226 | 227 | if images: 228 | big = parsed.info.get('msg-id') == 'gigantified-emote-message' 229 | msg_parsed = await parse_message_parts( 230 | msg=parsed, 231 | channel=config.channel, 232 | oauth_token=config.oauth_token_token, 233 | client_id=config.client_id, 234 | ) 235 | msg_s_images = await parsed_to_terminology(msg_parsed, big=big) 236 | else: 237 | msg_s_images = colorize(parsed.msg) 238 | 239 | if int(parsed.info.get('bits', '0')) % 100 == 69: 240 | msg_s_images = colorize(msg_s_images) 241 | 242 | if parsed.is_me: 243 | fmt = ( 244 | f'{dt_str()}' 245 | f'{{badges}}' 246 | f'{color_start}\033[3m * {parsed.display_name}\033[22m ' 247 | f'{{msg}}\033[m' 248 | ) 249 | elif parsed.bg_color is not None: 250 | bg_color_s = '{};{};{}'.format(*parsed.bg_color) 251 | fmt = ( 252 | f'{dt_str()}' 253 | f'{{badges}}' 254 | f'<{color_start}{parsed.display_name}\033[m> ' 255 | f'\033[48;2;{bg_color_s}m{{msg}}\033[m' 256 | ) 257 | else: 258 | fmt = ( 259 | f'{dt_str()}' 260 | f'{{badges}}' 261 | f'<{color_start}{parsed.display_name}\033[m> ' 262 | f'{{msg}}' 263 | ) 264 | 265 | to_print = fmt.format(badges=badges_s_images, msg=msg_s_images) 266 | to_log = fmt.format(badges=badges_s, msg=parsed.msg) 267 | return to_print, to_log 268 | 269 | return None 270 | 271 | 272 | async def amain(config: Config, *, quiet: bool, images: bool) -> None: 273 | log_writer = LogWriter() 274 | line_iter, writer = await connect(config, quiet=quiet) 275 | 276 | _start_periodic(config, writer, log_writer, quiet=quiet) 277 | 278 | async for data in line_iter: 279 | msg = data.decode('UTF-8', errors='backslashreplace') 280 | 281 | input_ret = await get_printed_input(config, msg, images=images) 282 | if input_ret is not None: 283 | to_print, to_log = input_ret 284 | print(to_print) 285 | log_writer.write_message(to_log) 286 | 287 | maybe_handler_match = get_handler(msg) 288 | if maybe_handler_match is not None: 289 | handler, match = maybe_handler_match 290 | coro = handle_response( 291 | config, match, handler, writer, log_writer, quiet=quiet, 292 | ) 293 | asyncio.get_event_loop().create_task(coro) 294 | elif msg.startswith('PING '): 295 | _, _, rest = msg.partition(' ') 296 | await send(writer, f'PONG {rest.rstrip()}\r\n', quiet=quiet) 297 | elif not quiet: 298 | print(f'UNHANDLED: {msg}', end='') 299 | 300 | 301 | async def chat_message_test( 302 | config: Config, 303 | msg: str, 304 | *, 305 | bits: int, 306 | mod: bool, 307 | user: str, 308 | ) -> None: 309 | line = get_fake_msg(config, msg, bits=bits, mod=mod, user=user) 310 | 311 | input_ret = await get_printed_input(config, line, images=False) 312 | assert input_ret is not None 313 | to_print, _ = input_ret 314 | print(to_print) 315 | 316 | maybe_handler_match = get_handler(line) 317 | if maybe_handler_match is not None: 318 | handler, match = maybe_handler_match 319 | result = await handler(config, match) 320 | if result is not None: 321 | printed_output = get_printed_output(config, result) 322 | if printed_output is not None: 323 | print(printed_output) 324 | else: 325 | print(result) 326 | else: 327 | print('<>') 328 | else: 329 | print('<>') 330 | 331 | 332 | def main() -> int: 333 | parser = argparse.ArgumentParser() 334 | parser.add_argument('--config', default='config.json') 335 | parser.add_argument('--verbose', action='store_true') 336 | parser.add_argument('--images', action='store_true') 337 | parser.add_argument('--test') 338 | parser.add_argument('--user', default='username') 339 | parser.add_argument('--bits', type=int, default=0) 340 | parser.add_argument('--mod', action='store_true') 341 | args = parser.parse_args() 342 | 343 | quiet = not args.verbose 344 | 345 | with open(args.config) as f: 346 | config = Config(**json.load(f)) 347 | 348 | if args.test: 349 | asyncio.run( 350 | chat_message_test( 351 | config, 352 | args.test, 353 | bits=args.bits, 354 | mod=args.mod, 355 | user=args.user, 356 | ), 357 | ) 358 | else: 359 | with contextlib.suppress(KeyboardInterrupt): 360 | asyncio.run(amain(config, quiet=quiet, images=args.images)) 361 | 362 | return 0 363 | 364 | 365 | if __name__ == '__main__': 366 | raise SystemExit(main()) 367 | --------------------------------------------------------------------------------