aboutsummaryrefslogtreecommitdiffstats
path: root/alfred/twt.py
diff options
context:
space:
mode:
Diffstat (limited to 'alfred/twt.py')
-rw-r--r--alfred/twt.py255
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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
+
+
+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}")