diff options
Diffstat (limited to 'alfred/twt.py')
| -rw-r--r-- | alfred/twt.py | 255 |
1 files changed, 253 insertions, 2 deletions
diff --git a/alfred/twt.py b/alfred/twt.py index 0953d3c..97bf5df 100644 --- a/alfred/twt.py +++ b/alfred/twt.py @@ -16,8 +16,10 @@ Config ([twt] section in sopel config): url - url value written in file header """ +import glob import os -from datetime import datetime +from datetime import datetime, timezone +from pathlib import Path from zoneinfo import ZoneInfo from sopel import plugin @@ -31,7 +33,209 @@ import helpstrings as h _pending_delete = {} -_SUBCOMMANDS = {"last", "count", "delete", "yes", "no"} +_SUBCOMMANDS = {"last", "count", "delete", "yes", "no", "add", "users", "rm"} + +WKFO_TOKENS = '/etc/nginx/twt-tokens.map' +WKFO_TWR = '/var/www/wk.fo/twt' + + +def _load_twt_tokens(): + tokens = {} + try: + with open(WKFO_TOKENS) as f: + for line in f: + line = line.strip() + if not line or line.startswith('#') or not line.startswith('"'): + continue + end = line.index('"', 1) + tok = line[1:end] + rest = line[end+1:].strip().rstrip(';').split('#')[0].strip() + if rest: + tokens[tok] = rest + except FileNotFoundError: + pass + return tokens + + +def _save_twt_tokens(tokens): + with open(WKFO_TOKENS, 'w') as f: + for tok, user in tokens.items(): + f.write(f'"{tok}" {user};\n') + + +def _esc(s): + return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') + + +def _load_twtxt(path): + twts = [] + try: + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + parts = line.split('\t', 1) + if len(parts) == 2: + try: + dt = datetime.fromisoformat(parts[0].replace('Z', '+00:00')) + except ValueError: + dt = datetime.min.replace(tzinfo=timezone.utc) + twts.append((dt, parts[1])) + except FileNotFoundError: + pass + return twts + + +_WKFO_HEAD = """\ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<title>{title}</title> +<link rel="icon" type="image/svg+xml" href="/favicon.svg"> +<style> +@font-face {{ font-family: "Kawkab Mono"; src: url(/fonts/KawkabMono-Regular.woff2); font-weight: normal; }} +@font-face {{ font-family: "Kawkab Mono"; src: url(/fonts/KawkabMono-Bold.woff2); font-weight: bold; }} +* {{ unicode-bidi: plaintext; box-sizing: border-box; }} +html {{ color: black; background-color: white; }} +body {{ font-family: "Kawkab Mono", monospace; font-size: 16px; line-height: 1.4; margin: 0; padding: 4rem 0; min-height: 100%; overflow-wrap: break-word; }} +main, header, footer {{ max-width: 800px; margin-inline: auto; padding: 0 2rem; }} +h1, footer {{ text-align: center; }} +p, h2, h3 {{ margin: 1em 0 0 0; }} +header {{ margin-bottom: 1em; }} +footer {{ margin-top: 3em; }} +hr {{ border: none; border-top: thin solid; margin: 1.25rem 0; }} +form {{ margin: 1em 0; }} +textarea, input[type=password] {{ font-family: inherit; font-size: inherit; width: 100%; border: thin solid; padding: 0.4em; box-sizing: border-box; }} +textarea {{ height: 5em; resize: vertical; }} +input[type=submit] {{ font-family: inherit; font-size: inherit; border: thin solid; padding: 0.3em 1em; cursor: pointer; background: transparent; margin-top: 0.5em; }} +ul {{ padding: 0 0 0 1.5em; margin: 1em 0; }} +li {{ margin: 0.5em 0; }} +.msg {{ min-height: 1.4em; }} +@media (max-width: 600px) {{ body {{ font-size: 0.9em; }} h1 {{ font-size: 1.8em; }} }} +@media (prefers-color-scheme: dark) {{ html {{ filter: invert(1); }} img {{ filter: invert(1); }} }} +</style> +</head> +<body>""" + +_WKFO_FOOTER = """\ +<footer> +<hr> +<a href="https://twt.gumx.cc">twt</a> / +<a href="https://feed.gumx.cc">feed</a> / +<a href="https://git.gumx.cc">git</a> / +<a href="https://mail.gumx.cc">mail</a> / +<a href="https://list.gumx.cc">list</a> / +<a href="https://irc.gumx.cc">irc</a> / +<a href="https://files.gumx.cc">files</a> / +<a href="https://vpn.gumx.cc">vpn</a> / +<a href="https://pgp.gumx.cc">pgp</a> / +<a href="https://demo.gumx.cc">demo</a> / +<a href="https://wk.fo">wk.fo</a> +<br> +<a href="https://git.gumx.cc/portal">source</a> / +<a href="https://gumx.cc/license">license</a> +</footer> +</body> +</html>""" + + +def _build_user_page(username): + twts = _load_twtxt(f'{WKFO_TWR}/~{username}/twtxt.txt') + items = '\n'.join( + f'<li>[{dt.strftime("%Y-%m-%d")}] {_esc(text)}</li>' + for dt, text in reversed(sorted(twts)) + ) or '<li>(no twts yet)</li>' + + return ( + _WKFO_HEAD.format(title=f'wk.fo / twt / ~{_esc(username)}') + '\n' + '<header>\n' + f'<nav><strong><a href="https://wk.fo">wk.fo</a></strong> / ' + f'<a href="/twt/">twt</a> / ~{_esc(username)}</nav>\n' + '</header>\n' + '<main>\n' + f'<h1>~{_esc(username)}</h1>\n' + f'<p><a href="/twt/~{_esc(username)}/twtxt.txt">twtxt feed</a></p>\n' + '<form method="post" action="/twt/submit">\n' + '<p><input type="password" name="token" placeholder="token" autocomplete="off"></p>\n' + '<p><textarea name="twt" placeholder="say something"></textarea></p>\n' + '<p><input type="submit" value="post"></p>\n' + '<p class="msg" id="msg"></p>\n' + '</form>\n' + '<script>\n' + 'document.querySelector("form").addEventListener("submit", async e => {\n' + ' e.preventDefault();\n' + ' const msg = document.getElementById("msg");\n' + ' msg.textContent = "";\n' + ' const r = await fetch("/twt/submit", {method: "POST", body: new URLSearchParams(new FormData(e.target))});\n' + ' const j = await r.json();\n' + ' if (j.ok) { location.reload(); } else { msg.textContent = j.error; }\n' + '});\n' + '</script>\n' + '<hr>\n' + '<ul>\n' + + items + '\n' + '</ul>\n' + '</main>\n' + + _WKFO_FOOTER + ) + + +def _build_index_page(): + users = sorted( + os.path.basename(d)[1:] + for d in glob.glob(f'{WKFO_TWR}/~*') + if os.path.isdir(d) + ) + + all_twts = [] + for u in users: + for dt, text in _load_twtxt(f'{WKFO_TWR}/~{u}/twtxt.txt'): + all_twts.append((dt, u, text)) + all_twts.sort(reverse=True) + + feed_items = '\n'.join( + f'<li>[{dt.strftime("%Y-%m-%d")}] ' + f'<a href="/twt/~{_esc(u)}/">~{_esc(u)}</a>: {_esc(text)}</li>' + for dt, u, text in all_twts[:50] + ) or '<li>(nothing yet)</li>' + + user_items = '\n'.join( + f'<li><a href="/twt/~{_esc(u)}/">~{_esc(u)}</a> ' + f'(<a href="/twt/~{_esc(u)}/twtxt.txt">twtxt</a>)</li>' + for u in users + ) or '<li>(no users yet)</li>' + + return ( + _WKFO_HEAD.format(title='wk.fo / twt') + '\n' + '<header>\n' + '<nav><strong><a href="https://wk.fo">wk.fo</a></strong> / twt</nav>\n' + '</header>\n' + '<main>\n' + '<h1>twt</h1>\n' + '<p>Hosted <a href="https://twtxt.readthedocs.io/">twtxt</a>. Short messages from friends.</p>\n' + '<h2>feed</h2>\n' + '<ul>\n' + feed_items + '\n</ul>\n' + '<h2>users</h2>\n' + '<ul>\n' + user_items + '\n</ul>\n' + '</main>\n' + + _WKFO_FOOTER + ) + + +def _wkfo_regen(bot, username=None): + try: + root = Path(WKFO_TWR) + root.mkdir(parents=True, exist_ok=True) + if username: + Path(f'{WKFO_TWR}/~{username}/index.html').write_text( + _build_user_page(username), encoding='utf-8') + Path(f'{WKFO_TWR}/index.html').write_text( + _build_index_page(), encoding='utf-8') + except Exception as e: + bot.say(f'regen error: {e}') class TwtSection(StaticSection): @@ -168,6 +372,53 @@ def cmd_twt(bot, trigger): bot.say("cancelled.") return + # --- wk.fo/twt user management --- + if sub == "users": + tokens = _load_twt_tokens() + if not tokens: + bot.say("no wk.fo/twt users") + return + users = sorted(set(tokens.values())) + bot.say(", ".join(users)) + return + + if sub == "add": + if len(args) < 3: + bot.say("usage: !twt add <username> <token>") + return + username = args[1] + token = args[2] + tokens = _load_twt_tokens() + if token in tokens: + bot.say(f"token already exists for {tokens[token]}") + return + user_dir = Path(f'{WKFO_TWR}/~{username}') + user_dir.mkdir(parents=True, exist_ok=True) + twtxt = user_dir / 'twtxt.txt' + if not twtxt.exists(): + twtxt.write_text(f'# nick = {username}\n# url = https://wk.fo/twt/~{username}/twtxt.txt\n') + tokens[token] = username + _save_twt_tokens(tokens) + _wkfo_regen(bot, username) + bot.say(f"added {username}") + return + + if sub == "rm": + if len(args) < 2: + bot.say("usage: !twt rm <username>") + return + target = args[1] + tokens = _load_twt_tokens() + before = len(tokens) + tokens = {t: u for t, u in tokens.items() if u != target} + if len(tokens) == before: + bot.say(f"not found: {target}") + return + _save_twt_tokens(tokens) + _wkfo_regen(bot) + bot.say(f"removed {target} (feed files kept)") + return + # --- post (anything that's not a subcommand) --- ts = _append(bot, raw) bot.say(f"twted [{ts[:10]}]: {raw}") |
