aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAhmed <git@gumx.cc>2026-06-20 16:08:04 +0300
committerAhmed <git@gumx.cc>2026-06-20 16:08:04 +0300
commit0aefb97844c495c0235b8e2c3c88eda9609fc8ee (patch)
treef1bd293d3df6b13f7f8fe66615c79f62786056ea
parent218f587a09f721ed494b4e3f491f950686cf6210 (diff)
fix: bit of vibed updateHEADmain
-rw-r--r--alfred/feed.py26
-rw-r--r--alfred/helpstrings.py8
-rw-r--r--alfred/twt.py255
3 files changed, 283 insertions, 6 deletions
diff --git a/alfred/feed.py b/alfred/feed.py
index b4c1c28..4839181 100644
--- a/alfred/feed.py
+++ b/alfred/feed.py
@@ -12,6 +12,7 @@ Commands (owner only):
import subprocess
import os
import sys
+from datetime import date as _date
from sopel import plugin
@@ -21,8 +22,9 @@ if _alfred_dir not in _sys.path:
_sys.path.insert(0, _alfred_dir)
import helpstrings as h
-FEEDS_FILE = '/home/ahmed/feeds.txt'
-FEED_SCRIPT = '/home/ahmed/scripts/feed-gen.py'
+FEEDS_FILE = '/home/ahmed/feeds.txt'
+READ_POINT_FILE = '/home/ahmed/feed-read.txt'
+FEED_SCRIPT = '/home/ahmed/scripts/feed-gen.py'
def _is_owner(bot, trigger):
@@ -128,4 +130,24 @@ def cmd_feed(bot, trigger):
_refresh(bot)
return
+ if sub == 'read':
+ if len(args) >= 2:
+ date_str = args[1].strip()
+ try:
+ from datetime import datetime
+ datetime.strptime(date_str, '%Y-%m-%d')
+ except ValueError:
+ bot.say('usage: !feed read [yyyy-mm-dd]')
+ return
+ else:
+ date_str = _date.today().strftime('%Y-%m-%d')
+ try:
+ with open(READ_POINT_FILE, 'w') as f:
+ f.write(date_str + '\n')
+ bot.say(f'read point set to {date_str}')
+ _refresh(bot)
+ except Exception as e:
+ bot.say(f'error: {e}')
+ return
+
bot.say(h.FEED_TOPIC)
diff --git a/alfred/helpstrings.py b/alfred/helpstrings.py
index 30fc996..f4e094f 100644
--- a/alfred/helpstrings.py
+++ b/alfred/helpstrings.py
@@ -77,21 +77,25 @@ GIT = {
"remove": "git remove: remove a repo, prompts for confirmation. usage: !git remove repo",
}
-TWF_TOPIC = "twt: last [n] count delete yes no <message>. type !help twt <cmd> for details"
+TWF_TOPIC = "twt: last [n] count delete yes no add users rm <message>. type !help twt <cmd> for details"
TWF = {
"last": "twt last: show last n twts, most recent first (default 5). usage: !twt last or !twt last n",
"count": "twt count: total number of twts. usage: !twt count",
"delete": "twt delete: delete the most recent twt, asks for confirmation. usage: !twt delete",
"yes": "twt yes or no: confirm or cancel a pending delete. usage: !twt yes or !twt no",
"no": "twt yes or no: confirm or cancel a pending delete. usage: !twt yes or !twt no",
+ "add": "twt add: provision a wk.fo/twt user and add their token. usage: !twt add <username> <token>",
+ "users": "twt users: list all wk.fo/twt users. usage: !twt users",
+ "rm": "twt rm: remove a wk.fo/twt user token (feed files kept). usage: !twt rm <username>",
}
-FEED_TOPIC = "feed: refresh add list rm. type !help feed <cmd> for details"
+FEED_TOPIC = "feed: refresh add list rm read. type !help feed <cmd> for details"
FEED = {
"refresh": "feed refresh: regenerate feed.gumx.cc from all followed feeds. usage: !feed refresh",
"add": "feed add: follow a feed. usage: !feed add <url> [nick]",
"list": "feed list: show all followed feeds. usage: !feed list",
"rm": "feed rm: remove a feed by nick or url. usage: !feed rm <nick>",
+ "read": "feed read: set read point and regenerate. items after the date are new, before are old. usage: !feed read or !feed read yyyy-mm-dd",
}
TOPIC_MAP = {
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}")