diff --git a/CLAUDE.md b/CLAUDE.md index eac7cff..f8103b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -467,6 +467,7 @@ Add-on skills (activated when corresponding add-on is installed): - `matrix-dendrite.md` — Matrix homeserver on Dendrite: create/join/leave rooms, send messages, sync, invite users, federation health; appservice registration prep for F.12 bridges; :8448-vs-well-known either/or federation story - `funkwhale.md` — Funkwhale federated music pod: library listing, search, upload, follow remote channels/libraries, playlists, listening history, moderation (block_user/mute inline; block_domain/defederate queued), media prune; on-disk or S3 audio storage via storage-translators.funkwhale() - `pixelfed.md` — Pixelfed federated photo-sharing: post photos (upload+status), feed, search, follow, moderation (block_user/mute inline; block_domain/defederate/import_blocklist queued), admin reports, remote reporting, media prune; Mastodon-compatible REST API; on-disk or S3 media via storage-translators.pixelfed() +- `lemmy.md` — Lemmy federated link aggregator: status, list/follow/unfollow communities, post (link + body), comment, feed (Subscribed/Local/All), search, moderation (block_user/block_community inline; block_instance/defederate queued), admin reports, pict-rs media prune; Lemmy v3 REST API; community-scoped federation - `calibre-server.md` — Calibre content server: search, browse, download ebooks via OPDS - `calibre-web.md` — Calibre-Web reader: search, shelves, reading status, download - `miniflux.md` — Miniflux RSS reader: subscribe feeds, read articles, star, mark read diff --git a/bundles/lemmy/docker-compose.yml b/bundles/lemmy/docker-compose.yml new file mode 100644 index 0000000..4be6d49 --- /dev/null +++ b/bundles/lemmy/docker-compose.yml @@ -0,0 +1,152 @@ +# Lemmy — federated link aggregator + discussion platform. +# +# Four-container bundle: lemmy (backend, Rust) + lemmy-ui (frontend) + +# postgres + pict-rs (image hosting). lemmy + lemmy-ui on crow-federation +# (Caddy reverse-proxies to lemmy-ui:1234); DB + pict-rs on default. +# +# Lemmy-UI serves the SPA AND proxies /api/v3/* to lemmy:8536 internally, +# so a single Caddy federation site is enough. +# +# Data: +# ~/.crow/lemmy/postgres/ Postgres data dir +# ~/.crow/lemmy/pictrs/ pict-rs sled db + media files +# ~/.crow/lemmy/lemmy.hjson Generated once at install time; not volume-mounted +# because lemmy reads env-var overrides too. +# +# Images: dessalines/lemmy:0.19 and dessalines/lemmy-ui:0.19 (floats +# within 0.19.x — verify latest + CVE feed at implementation time per the +# plan's image-tag policy). + +networks: + crow-federation: + external: true + default: + +services: + postgres: + image: postgres:16-alpine + container_name: crow-lemmy-postgres + networks: + - default + environment: + POSTGRES_USER: lemmy + POSTGRES_PASSWORD: ${LEMMY_DB_PASSWORD} + POSTGRES_DB: lemmy + volumes: + - ${LEMMY_DATA_DIR:-~/.crow/lemmy}/postgres:/var/lib/postgresql/data + init: true + mem_limit: 512m + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U lemmy"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + pictrs: + image: asonix/pictrs:0.5 + container_name: crow-lemmy-pictrs + user: "991:991" + networks: + - default + environment: + PICTRS__SERVER__API_KEY: ${LEMMY_PICTRS_API_KEY} + PICTRS__MEDIA__VIDEO_CODEC: vp9 + PICTRS__MEDIA__GIF__MAX_WIDTH: "256" + PICTRS__MEDIA__GIF__MAX_HEIGHT: "256" + PICTRS__MEDIA__GIF__MAX_AREA: "65536" + PICTRS__MEDIA__GIF__MAX_FRAME_COUNT: "400" + RUST_LOG: warn,tracing=warn + RUST_BACKTRACE: full + volumes: + - ${LEMMY_DATA_DIR:-~/.crow/lemmy}/pictrs:/mnt + init: true + mem_limit: 512m + restart: unless-stopped + + lemmy: + image: dessalines/lemmy:0.19 + container_name: crow-lemmy + hostname: lemmy + networks: + - default + - crow-federation + depends_on: + postgres: + condition: service_healthy + environment: + RUST_LOG: "warn" + RUST_BACKTRACE: "full" + LEMMY_DATABASE_URL: "postgres://lemmy:${LEMMY_DB_PASSWORD}@postgres:5432/lemmy" + LEMMY_CONFIG_LOCATION: /config/config.hjson + volumes: + - ./config:/config:ro + entrypoint: + - sh + - -c + - | + mkdir -p /config + cat > /config/config.hjson </dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 60s + + lemmy-ui: + image: dessalines/lemmy-ui:0.19 + container_name: crow-lemmy-ui + networks: + - default + - crow-federation + depends_on: + lemmy: + condition: service_healthy + environment: + LEMMY_UI_LEMMY_INTERNAL_HOST: "lemmy:8536" + LEMMY_UI_LEMMY_EXTERNAL_HOST: ${LEMMY_HOSTNAME} + LEMMY_UI_HTTPS: "true" + init: true + mem_limit: 256m + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:1234/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 60s diff --git a/bundles/lemmy/manifest.json b/bundles/lemmy/manifest.json new file mode 100644 index 0000000..26bd611 --- /dev/null +++ b/bundles/lemmy/manifest.json @@ -0,0 +1,45 @@ +{ + "id": "lemmy", + "name": "Lemmy", + "version": "1.0.0", + "description": "Federated link aggregator and discussion platform over ActivityPub — Reddit-alternative on the fediverse. Posts, comments, communities, votes; remote Lemmy/Mastodon/Kbin users can follow your communities and comment cross-server.", + "type": "bundle", + "author": "Crow", + "category": "federated-social", + "tags": ["lemmy", "activitypub", "fediverse", "link-aggregator", "discussion", "reddit-alt"], + "icon": "message-circle", + "docker": { "composefile": "docker-compose.yml" }, + "server": { + "command": "node", + "args": ["server/index.js"], + "envKeys": ["LEMMY_URL", "LEMMY_JWT", "LEMMY_HOSTNAME"] + }, + "panel": "panel/lemmy.js", + "panelRoutes": "panel/routes.js", + "skills": ["skills/lemmy.md"], + "consent_required": true, + "install_consent_messages": { + "en": "Lemmy joins the public fediverse over ActivityPub — your instance becomes publicly addressable at the domain you configure, any community you host or post you publish can be replicated to federated servers (other Lemmy instances, Kbin, Mastodon, Friendica) and cannot be fully recalled. Lemmy's pict-rs image cache grows with federated content: 5-20 GB within weeks is typical on an active instance. Lemmy is hardware-gated to refuse install on hosts with <1 GB effective RAM after committed bundles; warns below 4 GB total. Because Lemmy's federation is community-scoped rather than user-scoped, a single large community can pull heavy content from many remote instances — monitor disk. Hosting illegal content (CSAM, credible threats) is your legal responsibility — major hubs (lemmy.world, lemmy.ml) may defederate your instance if reports go unaddressed.", + "es": "Lemmy se une al fediverso público vía ActivityPub — tu instancia será direccionable en el dominio que configures, cualquier comunidad que hospedes o publicación que hagas puede replicarse a servidores federados (otras instancias de Lemmy, Kbin, Mastodon, Friendica) y no puede recuperarse completamente. El caché de imágenes pict-rs de Lemmy crece con el contenido federado: 5-20 GB en semanas es típico en una instancia activa. Lemmy está limitado por hardware: rechazado en hosts con <1 GB de RAM efectiva tras paquetes comprometidos; advierte bajo 4 GB totales. Como la federación de Lemmy es por comunidad (no por usuario), una sola comunidad grande puede traer contenido pesado de muchas instancias remotas — monitoriza el disco. Hospedar contenido ilegal (CSAM, amenazas creíbles) es tu responsabilidad legal — los hubs principales (lemmy.world, lemmy.ml) pueden dejar de federarse con tu instancia si los reportes no son atendidos." + }, + "requires": { + "env": ["LEMMY_HOSTNAME", "LEMMY_DB_PASSWORD", "LEMMY_PICTRS_API_KEY"], + "bundles": ["caddy"], + "min_ram_mb": 1000, + "recommended_ram_mb": 2000, + "min_disk_mb": 5000, + "recommended_disk_mb": 50000 + }, + "env_vars": [ + { "name": "LEMMY_HOSTNAME", "description": "Public domain (subdomain; path-mounts break ActivityPub).", "required": true }, + { "name": "LEMMY_DB_PASSWORD", "description": "Password for the bundled Postgres role.", "required": true, "secret": true }, + { "name": "LEMMY_PICTRS_API_KEY", "description": "Shared secret Lemmy uses to talk to pict-rs (any random 32+ char string).", "required": true, "secret": true }, + { "name": "LEMMY_JWT", "description": "Admin login JWT (obtain via POST /api/v3/user/login after registering the admin via the web UI).", "required": false, "secret": true }, + { "name": "LEMMY_URL", "description": "Internal URL the MCP server uses to reach Lemmy's API (default http://lemmy:8536 over crow-federation).", "default": "http://lemmy:8536", "required": false }, + { "name": "LEMMY_OPEN_REGISTRATION", "description": "Allow new user signups (true/false). Default false — opens registration only after moderation tooling is configured.", "default": "false", "required": false }, + { "name": "LEMMY_FEDERATION_ENABLED", "description": "Whether to federate with other ActivityPub servers (true/false).", "default": "true", "required": false } + ], + "ports": [], + "webUI": null, + "notes": "Four containers (lemmy + lemmy-ui + postgres + pict-rs). Expose via caddy_add_federation_site { domain: LEMMY_HOSTNAME, upstream: 'lemmy-ui:1234', profile: 'activitypub' }. The UI proxies /api/v3/ to lemmy:8536 internally. Initial admin registers via the /signup page during the first-boot setup wizard (no CLI bootstrap needed)." +} diff --git a/bundles/lemmy/package.json b/bundles/lemmy/package.json new file mode 100644 index 0000000..e171218 --- /dev/null +++ b/bundles/lemmy/package.json @@ -0,0 +1,11 @@ +{ + "name": "crow-lemmy", + "version": "1.0.0", + "description": "Lemmy (federated link aggregator) MCP server — posts, comments, communities, votes, moderation", + "type": "module", + "main": "server/index.js", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.24.0" + } +} diff --git a/bundles/lemmy/panel/lemmy.js b/bundles/lemmy/panel/lemmy.js new file mode 100644 index 0000000..f3d359a --- /dev/null +++ b/bundles/lemmy/panel/lemmy.js @@ -0,0 +1,154 @@ +/** + * Crow's Nest Panel — Lemmy: instance status + subscribed communities + hot posts. + * XSS-safe (textContent / createElement only). + */ + +export default { + id: "lemmy", + name: "Lemmy", + icon: "message-circle", + route: "/dashboard/lemmy", + navOrder: 76, + category: "federated-social", + + async handler(req, res, { layout }) { + const content = ` + +
+

Lemmy federated link aggregator

+ +
+

Status

+
Loading…
+
+ +
+

Local Communities

+
Loading…
+
+ +
+

Hot Posts (local)

+
Loading…
+
+ +
+

Notes

+
    +
  • Lemmy federation is community-scoped. A single large federated community can pull heavy content; monitor disk.
  • +
  • Moderation reports from all federated instances land in your admin queue. Review regularly.
  • +
  • pict-rs cache grows with federated image content. Tune retention via lemmy_media_prune.
  • +
+
+
+ + `; + res.send(layout({ title: "Lemmy", content })); + }, +}; + +function script() { + return ` + function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); } + function row(label, value) { + const r = document.createElement('div'); r.className = 'lm-row'; + const b = document.createElement('b'); b.textContent = label; + const s = document.createElement('span'); s.textContent = value == null ? '—' : String(value); + r.appendChild(b); r.appendChild(s); return r; + } + function err(msg) { const d = document.createElement('div'); d.className = 'np-error'; d.textContent = msg; return d; } + + async function loadStatus() { + const el = document.getElementById('lm-status'); clear(el); + try { + const res = await fetch('/api/lemmy/status'); const d = await res.json(); + if (d.error) { el.appendChild(err(d.error)); return; } + const card = document.createElement('div'); card.className = 'lm-card'; + card.appendChild(row('Site name', d.site_name || '(unset)')); + card.appendChild(row('Hostname', d.hostname || '—')); + card.appendChild(row('Version', d.version || '—')); + card.appendChild(row('Users', d.users ?? '—')); + card.appendChild(row('Posts', d.posts ?? '—')); + card.appendChild(row('Communities', d.communities ?? '—')); + card.appendChild(row('Federation', d.federation_enabled ? 'enabled' : 'disabled')); + card.appendChild(row('Registration', d.registration_mode || '—')); + card.appendChild(row('Authenticated', d.my_user || '(no JWT)')); + el.appendChild(card); + } catch (e) { el.appendChild(err('Cannot reach Lemmy.')); } + } + + async function loadCommunities() { + const el = document.getElementById('lm-communities'); clear(el); + try { + const res = await fetch('/api/lemmy/communities'); const d = await res.json(); + if (d.error) { el.appendChild(err(d.error)); return; } + if (!d.communities || d.communities.length === 0) { + const i = document.createElement('div'); i.className = 'np-idle'; + i.textContent = 'No local communities yet. Create one in the web UI.'; + el.appendChild(i); return; + } + for (const c of d.communities) { + const li = document.createElement('div'); li.className = 'lm-community'; + const t = document.createElement('b'); t.textContent = c.title || c.name; + li.appendChild(t); + const meta = document.createElement('div'); meta.className = 'lm-community-meta'; + meta.textContent = '!' + c.name + ' · ' + (c.subscribers || 0) + ' subs · ' + (c.posts || 0) + ' posts'; + li.appendChild(meta); + el.appendChild(li); + } + } catch (e) { el.appendChild(err('Cannot load communities: ' + e.message)); } + } + + async function loadPosts() { + const el = document.getElementById('lm-posts'); clear(el); + try { + const res = await fetch('/api/lemmy/posts'); const d = await res.json(); + if (d.error) { el.appendChild(err(d.error)); return; } + if (!d.posts || d.posts.length === 0) { + const i = document.createElement('div'); i.className = 'np-idle'; + i.textContent = 'No posts yet.'; + el.appendChild(i); return; + } + for (const p of d.posts) { + const c = document.createElement('div'); c.className = 'lm-post'; + const t = document.createElement('b'); t.textContent = p.name; + c.appendChild(t); + const meta = document.createElement('div'); meta.className = 'lm-post-meta'; + meta.textContent = '!' + (p.community || '?') + ' · ' + (p.score || 0) + ' pts · ' + (p.comments || 0) + ' comments · ' + (p.creator || '?'); + c.appendChild(meta); + el.appendChild(c); + } + } catch (e) { el.appendChild(err('Cannot load posts: ' + e.message)); } + } + + loadStatus(); + loadCommunities(); + loadPosts(); + `; +} + +function styles() { + return ` + .lm-panel h1 { margin: 0 0 1rem; font-size: 1.5rem; } + .lm-subtitle { font-size: 0.85rem; color: var(--crow-text-muted); font-weight: 400; margin-left: .5rem; } + .lm-section { margin-bottom: 1.8rem; } + .lm-section h3 { font-size: 0.8rem; color: var(--crow-text-muted); text-transform: uppercase; + letter-spacing: 0.05em; margin: 0 0 0.7rem; } + .lm-card { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border); + border-radius: 10px; padding: 1rem; } + .lm-row { display: flex; justify-content: space-between; padding: .25rem 0; font-size: .9rem; color: var(--crow-text-primary); } + .lm-row b { color: var(--crow-text-muted); font-weight: 500; min-width: 160px; } + .lm-community, .lm-post { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border); + border-radius: 8px; padding: .6rem .9rem; margin-bottom: .4rem; } + .lm-community b, .lm-post b { color: var(--crow-text-primary); font-size: .9rem; } + .lm-community-meta, .lm-post-meta { font-size: .75rem; color: var(--crow-text-muted); margin-top: .2rem; font-family: ui-monospace, monospace; } + .lm-notes ul { margin: 0; padding-left: 1.2rem; color: var(--crow-text-secondary); font-size: .88rem; } + .lm-notes li { margin-bottom: .3rem; } + .lm-notes code { font-family: ui-monospace, monospace; background: var(--crow-bg); + padding: 1px 4px; border-radius: 3px; font-size: .8em; } + .np-idle, .np-loading { color: var(--crow-text-muted); font-size: 0.9rem; padding: 1rem; + background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; } + .np-error { color: #ef4444; font-size: 0.9rem; padding: 1rem; + background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; } + `; +} diff --git a/bundles/lemmy/panel/routes.js b/bundles/lemmy/panel/routes.js new file mode 100644 index 0000000..c90709d --- /dev/null +++ b/bundles/lemmy/panel/routes.js @@ -0,0 +1,92 @@ +/** + * Lemmy panel API routes — status, communities, hot posts. + */ + +import { Router } from "express"; + +const URL_BASE = () => (process.env.LEMMY_URL || "http://lemmy:8536").replace(/\/+$/, ""); +const JWT = () => process.env.LEMMY_JWT || ""; +const HOSTNAME = () => process.env.LEMMY_HOSTNAME || ""; +const TIMEOUT = 15_000; + +async function lem(path, { query, noAuth } = {}) { + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), TIMEOUT); + try { + const qs = query + ? "?" + + Object.entries(query) + .filter(([, v]) => v != null && v !== "") + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join("&") + : ""; + const headers = {}; + if (!noAuth && JWT()) headers.Authorization = `Bearer ${JWT()}`; + const r = await fetch(`${URL_BASE()}${path}${qs}`, { signal: ctl.signal, headers }); + if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); + const text = await r.text(); + return text ? JSON.parse(text) : {}; + } finally { + clearTimeout(t); + } +} + +export default function lemmyRouter(authMiddleware) { + const router = Router(); + + router.get("/api/lemmy/status", authMiddleware, async (_req, res) => { + try { + const site = await lem("/api/v3/site", { noAuth: !JWT() }); + res.json({ + hostname: HOSTNAME(), + version: site.version, + site_name: site.site_view?.site?.name, + users: site.site_view?.counts?.users, + posts: site.site_view?.counts?.posts, + communities: site.site_view?.counts?.communities, + federation_enabled: site.site_view?.local_site?.federation_enabled, + registration_mode: site.site_view?.local_site?.registration_mode, + my_user: site.my_user?.local_user_view?.person?.name, + }); + } catch (err) { + res.json({ error: `Cannot reach Lemmy: ${err.message}` }); + } + }); + + router.get("/api/lemmy/communities", authMiddleware, async (_req, res) => { + try { + const out = await lem("/api/v3/community/list", { query: { type_: "Local", sort: "Active", limit: 15 }, noAuth: !JWT() }); + res.json({ + communities: (out.communities || []).map((c) => ({ + id: c.community.id, + name: c.community.name, + title: c.community.title, + subscribers: c.counts?.subscribers, + posts: c.counts?.posts, + })), + }); + } catch (err) { + res.json({ error: err.message }); + } + }); + + router.get("/api/lemmy/posts", authMiddleware, async (_req, res) => { + try { + const out = await lem("/api/v3/post/list", { query: { type_: "Local", sort: "Hot", limit: 12 }, noAuth: !JWT() }); + res.json({ + posts: (out.posts || []).map((p) => ({ + id: p.post?.id, + name: p.post?.name, + community: p.community?.name, + creator: p.creator?.name, + score: p.counts?.score, + comments: p.counts?.comments, + })), + }); + } catch (err) { + res.json({ error: err.message }); + } + }); + + return router; +} diff --git a/bundles/lemmy/scripts/backup.sh b/bundles/lemmy/scripts/backup.sh new file mode 100755 index 0000000..ccccc6b --- /dev/null +++ b/bundles/lemmy/scripts/backup.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Lemmy backup: pg_dump + pict-rs sled DB + media files. +set -euo pipefail + +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +BACKUP_ROOT="${CROW_HOME:-$HOME/.crow}/backups/lemmy" +DATA_DIR="${LEMMY_DATA_DIR:-$HOME/.crow/lemmy}" + +mkdir -p "$BACKUP_ROOT" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Postgres dump +if docker ps --format '{{.Names}}' | grep -qw crow-lemmy-postgres; then + docker exec -e PGPASSWORD="${LEMMY_DB_PASSWORD:-}" crow-lemmy-postgres \ + pg_dump -U lemmy -Fc -f /tmp/lemmy-${STAMP}.pgcustom lemmy + docker cp "crow-lemmy-postgres:/tmp/lemmy-${STAMP}.pgcustom" "$WORK/lemmy.pgcustom" + docker exec crow-lemmy-postgres rm "/tmp/lemmy-${STAMP}.pgcustom" +fi + +# pict-rs sled + files +tar -C "$DATA_DIR/pictrs" -cf "$WORK/pictrs.tar" . 2>/dev/null || true + +OUT="${BACKUP_ROOT}/lemmy-${STAMP}.tar.zst" +if command -v zstd >/dev/null 2>&1; then + tar -C "$WORK" -cf - . | zstd -T0 -19 -o "$OUT" +else + OUT="${BACKUP_ROOT}/lemmy-${STAMP}.tar.gz" + tar -C "$WORK" -czf "$OUT" . +fi +echo "wrote $OUT ($(du -h "$OUT" | cut -f1))" +echo "NOTE: lemmy's federation identity lives in the database (instance private key)." +echo " Restoring to a different LEMMY_HOSTNAME will break federation." diff --git a/bundles/lemmy/scripts/post-install.sh b/bundles/lemmy/scripts/post-install.sh new file mode 100755 index 0000000..ab52113 --- /dev/null +++ b/bundles/lemmy/scripts/post-install.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Lemmy post-install hook. +# +# 1. Wait for crow-lemmy to report healthy (first-boot migrations + pict-rs +# sled DB init). +# 2. Verify crow-federation network attachment on lemmy + lemmy-ui. +# 3. Print next-step guidance (Caddy site, setup wizard, JWT login). + +set -euo pipefail + +BUNDLE_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${BUNDLE_DIR}/.env" +if [ -f "$ENV_FILE" ]; then + set -a; . "$ENV_FILE"; set +a +fi + +echo "Waiting for Lemmy to report healthy (up to 120s)…" +for i in $(seq 1 24); do + if docker inspect crow-lemmy --format '{{.State.Health.Status}}' 2>/dev/null | grep -qw healthy; then + echo " → lemmy healthy" + break + fi + sleep 5 +done + +echo "Waiting for Lemmy-UI to report healthy (up to 60s)…" +for i in $(seq 1 12); do + if docker inspect crow-lemmy-ui --format '{{.State.Health.Status}}' 2>/dev/null | grep -qw healthy; then + echo " → lemmy-ui healthy" + break + fi + sleep 5 +done + +for c in crow-lemmy crow-lemmy-ui; do + if ! docker inspect "$c" --format '{{range $k, $_ := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | grep -qw crow-federation; then + echo "WARN: $c is not on the crow-federation network — Caddy federation site will not reach it by service name" >&2 + fi +done + +cat <}/ and complete the setup + wizard (admin username + password + site name). The compose + entrypoint writes a placeholder admin_pending account; the wizard + replaces it. + + 3. Obtain a JWT: + curl -X POST https://${LEMMY_HOSTNAME:-}/api/v3/user/login \\ + -H 'Content-Type: application/json' \\ + -d '{"username_or_email":"admin","password":""}' + Paste the returned jwt into .env as LEMMY_JWT, then: + crow bundle restart lemmy + + 4. Verify: + lemmy_status {} + +EOF diff --git a/bundles/lemmy/server/index.js b/bundles/lemmy/server/index.js new file mode 100755 index 0000000..3cdccc6 --- /dev/null +++ b/bundles/lemmy/server/index.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createLemmyServer } from "./server.js"; + +const server = await createLemmyServer(); +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/bundles/lemmy/server/server.js b/bundles/lemmy/server/server.js new file mode 100644 index 0000000..0105e25 --- /dev/null +++ b/bundles/lemmy/server/server.js @@ -0,0 +1,551 @@ +/** + * Lemmy MCP Server + * + * Lemmy exposes a JSON REST API at /api/v3/. Authentication uses a JWT + * passed as the `auth` body field (v3 quirk) or Authorization: Bearer. + * Federation is community-scoped rather than user-scoped — following a + * community on a remote instance pulls all its posts to the local server. + * + * Tools (federated link-aggregator taxonomy): + * lemmy_status + * lemmy_list_communities + * lemmy_follow_community + * lemmy_unfollow_community + * lemmy_post — create a post (title + url or body) + * lemmy_comment — reply to a post or comment + * lemmy_feed — list posts (subscribed/all/local) + * lemmy_search + * lemmy_block_user — inline, rate-limited + * lemmy_block_community — inline, rate-limited + * lemmy_block_instance — QUEUED (admin, destructive) + * lemmy_defederate — QUEUED + * lemmy_review_reports — list open reports (admin read-only) + * lemmy_media_prune — pict-rs purge of remote media + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +const LEMMY_URL = (process.env.LEMMY_URL || "http://lemmy:8536").replace(/\/+$/, ""); +const LEMMY_JWT = process.env.LEMMY_JWT || ""; +const LEMMY_HOSTNAME = process.env.LEMMY_HOSTNAME || ""; + +let wrapRateLimited = null; +let getDb = null; +let createNotification = null; + +async function loadSharedDeps() { + try { + const rl = await import("../../../servers/shared/rate-limiter.js"); + wrapRateLimited = rl.wrapRateLimited; + } catch { + wrapRateLimited = () => (_toolId, handler) => handler; + } + try { + const db = await import("../../../servers/db.js"); + getDb = db.createDbClient; + } catch { + getDb = null; + } + try { + const notif = await import("../../../servers/shared/notifications.js"); + createNotification = notif.createNotification; + } catch { + createNotification = null; + } +} + +async function lemFetch(path, { method = "GET", body, query, noAuth, timeoutMs = 20_000 } = {}) { + const qs = query + ? "?" + + Object.entries(query) + .filter(([, v]) => v != null && v !== "") + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join("&") + : ""; + const url = `${LEMMY_URL}${path}${qs}`; + const headers = { "Content-Type": "application/json" }; + if (!noAuth && LEMMY_JWT) headers.Authorization = `Bearer ${LEMMY_JWT}`; + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), timeoutMs); + try { + const res = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: ctl.signal, + }); + const text = await res.text(); + if (!res.ok) { + const snippet = text.slice(0, 600); + if (res.status === 401) throw new Error("Lemmy auth failed (401). Log in via POST /api/v3/user/login to obtain a JWT; paste into LEMMY_JWT."); + if (res.status === 403) throw new Error(`Lemmy forbidden (403)${snippet ? ": " + snippet : ""}`); + throw new Error(`Lemmy ${res.status} ${res.statusText}${snippet ? " — " + snippet : ""}`); + } + if (!text) return {}; + try { return JSON.parse(text); } catch { return { raw: text }; } + } catch (err) { + if (err.name === "AbortError") throw new Error(`Lemmy request timed out: ${path}`); + if (err.cause?.code === "ECONNREFUSED" || err.message?.includes("ECONNREFUSED")) { + throw new Error(`Cannot reach Lemmy at ${LEMMY_URL}. Verify crow-lemmy is up and on the crow-federation network.`); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +function requireAuth() { + if (!LEMMY_JWT) { + return { content: [{ type: "text", text: "Error: LEMMY_JWT required. Log in via POST /api/v3/user/login to obtain one; paste into LEMMY_JWT and restart the MCP server." }] }; + } + return null; +} + +async function queueModerationAction(bundle, actionType, payload) { + if (!getDb) { + return { + status: "queued_offline", + reason: "Crow database not reachable from bundle — moderation queue unavailable. Action NOT applied.", + requested: { action_type: actionType, payload }, + }; + } + const db = getDb(); + try { + const now = Math.floor(Date.now() / 1000); + const expiresAt = now + 72 * 3600; + const payloadJson = JSON.stringify(payload); + const { createHash } = await import("node:crypto"); + const idempotencyKey = createHash("sha256").update(`${bundle}:${actionType}:${payloadJson}`).digest("hex"); + + const existing = await db.execute({ + sql: "SELECT id, expires_at, status FROM moderation_actions WHERE idempotency_key = ?", + args: [idempotencyKey], + }); + if (existing.rows.length > 0) { + return { status: "queued_duplicate", action_id: Number(existing.rows[0].id), previous_status: existing.rows[0].status }; + } + + const inserted = await db.execute({ + sql: `INSERT INTO moderation_actions + (bundle_id, action_type, payload_json, requested_by, + requested_at, expires_at, status, idempotency_key) + VALUES (?, ?, ?, 'ai', ?, ?, 'pending', ?) + RETURNING id`, + args: [bundle, actionType, payloadJson, now, expiresAt, idempotencyKey], + }); + const actionId = Number(inserted.rows[0].id); + + if (createNotification) { + try { + await createNotification(db, { + title: `${bundle} moderation action awaiting confirmation`, + body: `${actionType} — review and confirm in the Nest panel before ${new Date(expiresAt * 1000).toLocaleString()}`, + type: "system", + source: bundle, + priority: "high", + action_url: `/dashboard/${bundle}?action=${actionId}`, + }); + } catch {} + } + + return { status: "queued", action_id: actionId, expires_at: expiresAt }; + } catch (err) { + if (/no such table.*moderation_actions/i.test(err.message)) { + return { status: "queued_unavailable", reason: "moderation_actions table not present — lands with F.11." }; + } + throw err; + } finally { + try { db.close(); } catch {} + } +} + +function textResponse(obj) { + return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] }; +} + +function errResponse(err) { + return { content: [{ type: "text", text: `Error: ${err.message || String(err)}` }] }; +} + +/** + * Resolve a community name ("community@server" or "!community@server") + * to a local community_id via Lemmy's search API. + */ +async function resolveCommunity(nameOrId) { + if (typeof nameOrId === "number" || /^\d+$/.test(nameOrId)) return Number(nameOrId); + const clean = nameOrId.replace(/^!/, ""); + const out = await lemFetch("/api/v3/search", { + query: { q: clean, type_: "Communities", limit: 1 }, + }); + const match = (out.communities || [])[0]; + if (!match) throw new Error(`Community not found: ${nameOrId}`); + return match.community.id; +} + +async function resolvePerson(handleOrId) { + if (typeof handleOrId === "number" || /^\d+$/.test(handleOrId)) return Number(handleOrId); + const clean = handleOrId.replace(/^@/, ""); + const out = await lemFetch("/api/v3/search", { + query: { q: clean, type_: "Users", limit: 1 }, + }); + const match = (out.users || [])[0]; + if (!match) throw new Error(`User not found: ${handleOrId}`); + return match.person.id; +} + +export async function createLemmyServer(options = {}) { + await loadSharedDeps(); + + const server = new McpServer( + { name: "crow-lemmy", version: "1.0.0" }, + { instructions: options.instructions }, + ); + + const limiter = wrapRateLimited ? wrapRateLimited({ db: getDb ? getDb() : null }) : (_, h) => h; + + // --- lemmy_status --- + server.tool( + "lemmy_status", + "Report Lemmy instance health: site info, federation mode, admin list, user stats, open registrations.", + {}, + async () => { + try { + const site = await lemFetch("/api/v3/site", { noAuth: !LEMMY_JWT }); + return textResponse({ + hostname: LEMMY_HOSTNAME || null, + url: LEMMY_URL, + version: site.version || null, + site_name: site.site_view?.site?.name || null, + description: site.site_view?.site?.description || null, + registration_mode: site.site_view?.local_site?.registration_mode || null, + federation_enabled: site.site_view?.local_site?.federation_enabled ?? null, + users: site.site_view?.counts?.users || null, + posts: site.site_view?.counts?.posts || null, + comments: site.site_view?.counts?.comments || null, + communities: site.site_view?.counts?.communities || null, + admins: (site.admins || []).map((a) => a.person?.name), + my_user: site.my_user?.local_user_view?.person?.name || null, + has_jwt: Boolean(LEMMY_JWT), + }); + } catch (err) { + return errResponse(err); + } + }, + ); + + // --- lemmy_list_communities --- + server.tool( + "lemmy_list_communities", + "List communities. Default scope 'Local' returns this-instance communities; 'All' includes federated communities your instance has fetched.", + { + type_: z.enum(["Local", "All", "Subscribed"]).optional(), + sort: z.enum(["TopAll", "TopMonth", "TopWeek", "Hot", "New", "Active"]).optional(), + limit: z.number().int().min(1).max(50).optional(), + page: z.number().int().min(1).max(1000).optional(), + }, + async ({ type_, sort, limit, page }) => { + try { + const out = await lemFetch("/api/v3/community/list", { + query: { type_: type_ || "Local", sort: sort || "Active", limit: limit ?? 20, page }, + }); + return textResponse({ + count: (out.communities || []).length, + communities: (out.communities || []).map((c) => ({ + id: c.community.id, + name: c.community.name, + title: c.community.title, + actor_id: c.community.actor_id, + subscribers: c.counts?.subscribers, + posts: c.counts?.posts, + subscribed: c.subscribed, + })), + }); + } catch (err) { + return errResponse(err); + } + }, + ); + + // --- lemmy_follow_community / unfollow --- + server.tool( + "lemmy_follow_community", + "Subscribe to a community (local or federated). Accepts numeric community ID or 'community@instance' handle. Rate-limited: 30/hour.", + { community: z.union([z.string().min(1).max(500), z.number().int()]) }, + limiter("lemmy_follow_community", async ({ community }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const id = await resolveCommunity(community); + const out = await lemFetch("/api/v3/community/follow", { method: "POST", body: { community_id: id, follow: true } }); + return textResponse({ community_id: id, subscribed: out.community_view?.subscribed }); + } catch (err) { + return errResponse(err); + } + }), + ); + + server.tool( + "lemmy_unfollow_community", + "Unsubscribe from a community.", + { community: z.union([z.string().min(1).max(500), z.number().int()]) }, + limiter("lemmy_unfollow_community", async ({ community }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const id = await resolveCommunity(community); + const out = await lemFetch("/api/v3/community/follow", { method: "POST", body: { community_id: id, follow: false } }); + return textResponse({ community_id: id, subscribed: out.community_view?.subscribed }); + } catch (err) { + return errResponse(err); + } + }), + ); + + // --- lemmy_post --- + server.tool( + "lemmy_post", + "Create a post in a community. Either url (link post) or body (text post) must be set — or both. Rate-limited: 10/hour.", + { + community: z.union([z.string().min(1).max(500), z.number().int()]), + name: z.string().min(3).max(200).describe("Post title (3-200 chars)."), + url: z.string().url().max(2000).optional(), + body: z.string().max(50_000).optional(), + nsfw: z.boolean().optional(), + language_id: z.number().int().optional(), + }, + limiter("lemmy_post", async (args) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + if (!args.url && !args.body) { + return { content: [{ type: "text", text: "Error: post must have url, body, or both." }] }; + } + const community_id = await resolveCommunity(args.community); + const body = { + name: args.name, community_id, + ...(args.url ? { url: args.url } : {}), + ...(args.body ? { body: args.body } : {}), + ...(args.nsfw != null ? { nsfw: args.nsfw } : {}), + ...(args.language_id ? { language_id: args.language_id } : {}), + }; + const out = await lemFetch("/api/v3/post", { method: "POST", body }); + return textResponse({ + post_id: out.post_view?.post?.id, + ap_id: out.post_view?.post?.ap_id, + published: out.post_view?.post?.published, + }); + } catch (err) { + return errResponse(err); + } + }), + ); + + // --- lemmy_comment --- + server.tool( + "lemmy_comment", + "Reply to a post or another comment. Pass post_id (required) and optional parent_id for replies to comments. Rate-limited: 20/hour.", + { + post_id: z.number().int(), + parent_id: z.number().int().optional(), + content: z.string().min(1).max(20_000), + language_id: z.number().int().optional(), + }, + limiter("lemmy_comment", async (args) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const out = await lemFetch("/api/v3/comment", { method: "POST", body: args }); + return textResponse({ + comment_id: out.comment_view?.comment?.id, + ap_id: out.comment_view?.comment?.ap_id, + published: out.comment_view?.comment?.published, + }); + } catch (err) { + return errResponse(err); + } + }), + ); + + // --- lemmy_feed --- + server.tool( + "lemmy_feed", + "Fetch posts. type_: Subscribed (follows), Local (this instance), All (federated). Rate-limited: 60/hour.", + { + type_: z.enum(["Subscribed", "Local", "All"]).optional(), + sort: z.enum(["Active", "Hot", "New", "TopDay", "TopWeek", "TopMonth", "TopAll"]).optional(), + limit: z.number().int().min(1).max(50).optional(), + page: z.number().int().min(1).max(1000).optional(), + community: z.union([z.string(), z.number().int()]).optional().describe("Scope to one community."), + }, + limiter("lemmy_feed", async ({ type_, sort, limit, page, community }) => { + try { + const query = { type_: type_ || "Local", sort: sort || "Hot", limit: limit ?? 20, page }; + if (community) query.community_id = await resolveCommunity(community); + const out = await lemFetch("/api/v3/post/list", { query, noAuth: !LEMMY_JWT && type_ !== "Subscribed" }); + return textResponse({ + count: (out.posts || []).length, + posts: (out.posts || []).map((p) => ({ + id: p.post?.id, + name: p.post?.name, + url: p.post?.url, + body_excerpt: (p.post?.body || "").slice(0, 240), + community: p.community?.name, + creator: p.creator?.name, + score: p.counts?.score, + comments: p.counts?.comments, + published: p.post?.published, + ap_id: p.post?.ap_id, + })), + }); + } catch (err) { + return errResponse(err); + } + }), + ); + + // --- lemmy_search --- + server.tool( + "lemmy_search", + "Search across communities, posts, comments, users. Rate-limited: 60/hour.", + { + q: z.string().min(1).max(500), + type_: z.enum(["All", "Comments", "Posts", "Communities", "Users", "Url"]).optional(), + limit: z.number().int().min(1).max(50).optional(), + }, + limiter("lemmy_search", async ({ q, type_, limit }) => { + try { + const out = await lemFetch("/api/v3/search", { query: { q, type_: type_ || "All", limit: limit ?? 10 } }); + return textResponse({ + posts: (out.posts || []).slice(0, 10).map((p) => ({ id: p.post?.id, name: p.post?.name, community: p.community?.name })), + comments: (out.comments || []).slice(0, 10).map((c) => ({ id: c.comment?.id, excerpt: (c.comment?.content || "").slice(0, 120) })), + communities: (out.communities || []).slice(0, 10).map((c) => ({ id: c.community?.id, name: c.community?.name, actor_id: c.community?.actor_id })), + users: (out.users || []).slice(0, 10).map((u) => ({ id: u.person?.id, name: u.person?.name, actor_id: u.person?.actor_id })), + }); + } catch (err) { + return errResponse(err); + } + }), + ); + + // --- User-level moderation (inline) --- + server.tool( + "lemmy_block_user", + "Block a person (hide their posts + comments from your view). Rate-limited: 5/hour.", + { user: z.union([z.string().min(1).max(500), z.number().int()]), confirm: z.literal("yes") }, + limiter("lemmy_block_user", async ({ user }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const person_id = await resolvePerson(user); + const out = await lemFetch("/api/v3/user/block", { method: "POST", body: { person_id, block: true } }); + return textResponse({ person_id, blocked: out.blocked }); + } catch (err) { + return errResponse(err); + } + }), + ); + + server.tool( + "lemmy_block_community", + "Block a community (hide all its posts from your feeds). Rate-limited: 5/hour.", + { community: z.union([z.string().min(1).max(500), z.number().int()]), confirm: z.literal("yes") }, + limiter("lemmy_block_community", async ({ community }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const community_id = await resolveCommunity(community); + const out = await lemFetch("/api/v3/community/block", { method: "POST", body: { community_id, block: true } }); + return textResponse({ community_id, blocked: out.blocked }); + } catch (err) { + return errResponse(err); + } + }), + ); + + // --- Instance-level moderation (QUEUED) --- + server.tool( + "lemmy_block_instance", + "Block an entire remote instance (no federation, no fetched content). Admin-only; QUEUED — requires operator confirmation in the Nest panel.", + { + instance: z.string().min(3).max(253), + reason: z.string().max(1000).optional(), + confirm: z.literal("yes"), + }, + async ({ instance, reason }) => { + const queued = await queueModerationAction("lemmy", "block_instance", { instance, reason: reason || "" }); + return textResponse(queued); + }, + ); + + server.tool( + "lemmy_defederate", + "Defederate from a remote instance (block + purge cached content). Admin-only; QUEUED.", + { + instance: z.string().min(3).max(253), + reason: z.string().max(1000).optional(), + confirm: z.literal("yes"), + }, + async ({ instance, reason }) => { + const queued = await queueModerationAction("lemmy", "defederate", { instance, reason: reason || "" }); + return textResponse(queued); + }, + ); + + // --- lemmy_review_reports (admin read-only) --- + server.tool( + "lemmy_review_reports", + "List open post + comment reports (admin / mod only). Read-only summary.", + { limit: z.number().int().min(1).max(50).optional() }, + async ({ limit }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const [posts, comments] = await Promise.all([ + lemFetch("/api/v3/post/report/list", { query: { limit: limit ?? 20, unresolved_only: "true" } }).catch(() => null), + lemFetch("/api/v3/comment/report/list", { query: { limit: limit ?? 20, unresolved_only: "true" } }).catch(() => null), + ]); + return textResponse({ + post_reports: (posts?.post_reports || []).map((r) => ({ + id: r.post_report?.id, + reason: r.post_report?.reason, + reporter: r.creator?.name, + post: r.post?.name, + published: r.post_report?.published, + })), + comment_reports: (comments?.comment_reports || []).map((r) => ({ + id: r.comment_report?.id, + reason: r.comment_report?.reason, + reporter: r.creator?.name, + comment_excerpt: (r.comment?.content || "").slice(0, 120), + published: r.comment_report?.published, + })), + }); + } catch (err) { + return errResponse(err); + } + }, + ); + + // --- lemmy_media_prune --- + server.tool( + "lemmy_media_prune", + "Trigger a pict-rs prune of remote media. Exposes the admin purge endpoint; deletes cached media for federated posts older than N days. Rate-limited: 2/hour.", + { + older_than_days: z.number().int().min(1).max(365).optional(), + confirm: z.literal("yes"), + }, + limiter("lemmy_media_prune", async ({ older_than_days }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const days = older_than_days ?? 14; + const out = await lemFetch("/api/v3/admin/purge/post", { + method: "POST", + body: { older_than_days: days, reason: `crow-media-prune ${days}d` }, + }).catch(() => null); + return textResponse({ + requested_days: days, + response: out, + note: out ? null : "Admin purge endpoint unavailable on this Lemmy version — pict-rs handles its own retention via PICTRS__* env vars.", + }); + } catch (err) { + return errResponse(err); + } + }), + ); + + return server; +} diff --git a/bundles/lemmy/skills/lemmy.md b/bundles/lemmy/skills/lemmy.md new file mode 100644 index 0000000..0aaf988 --- /dev/null +++ b/bundles/lemmy/skills/lemmy.md @@ -0,0 +1,120 @@ +--- +name: lemmy +description: Lemmy — federated link aggregator + discussion platform. Posts, comments, communities, votes, moderation over ActivityPub. +triggers: + - "lemmy" + - "link aggregator" + - "reddit alternative" + - "subscribe community" + - "post link" + - "fediverse discussion" + - "upvote" +tools: + - lemmy_status + - lemmy_list_communities + - lemmy_follow_community + - lemmy_unfollow_community + - lemmy_post + - lemmy_comment + - lemmy_feed + - lemmy_search + - lemmy_block_user + - lemmy_block_community + - lemmy_block_instance + - lemmy_defederate + - lemmy_review_reports + - lemmy_media_prune +--- + +# Lemmy — federated link aggregator + +Lemmy is the fediverse's Reddit-alternative: link posts, threaded comments, community subscriptions, upvotes. Federation is **community-scoped** (not user-scoped like Mastodon) — following a community on a remote Lemmy/Kbin pulls all its posts and comments to your instance. + +## Hardware + +Gated by F.0's hardware check. Refused below **1 GB effective RAM after committed bundles**, warned below 4 GB total. Pi-class hosts (4-8 GB) handle Lemmy fine when it's the only federated bundle. Disk growth is driven by pict-rs federated image cache — 5-20 GB within weeks of active federation. + +## First-run bootstrap + +1. After install, expose via Caddy: + ``` + caddy_add_federation_site { + domain: "lemmy.example.com", + upstream: "lemmy-ui:1234", + profile: "activitypub" + } + ``` +2. Open https://lemmy.example.com/ and complete the setup wizard (admin username + password + site name). The compose entrypoint writes a placeholder `admin_pending` account; the web wizard replaces it. +3. Obtain an auth JWT: + ```bash + curl -X POST https://lemmy.example.com/api/v3/user/login \ + -H 'Content-Type: application/json' \ + -d '{"username_or_email":"admin","password":""}' + ``` + Copy `jwt` from the response into `.env` as `LEMMY_JWT`, then `crow bundle restart lemmy`. + +## Common workflows + +### Follow a federated community + +``` +lemmy_follow_community { "community": "technology@lemmy.world" } +``` + +First follow against a given remote instance takes 30+ seconds (WebFinger + ActivityPub inbox handshake + initial post backfill). Subsequent follows to that same instance are fast. + +### Post a link + +``` +lemmy_post { + "community": "technology@lemmy.world", + "name": "New Rust release notes", + "url": "https://blog.rust-lang.org/2026/04/10/Rust-1.99.0.html", + "nsfw": false +} +``` + +Text-post variant: omit `url`, add `body` (Markdown supported). + +### Comment + +``` +lemmy_feed { "type_": "Subscribed", "sort": "Hot", "limit": 10 } +# → find a post_id + +lemmy_comment { + "post_id": 12345, + "content": "Interesting take — have you seen the follow-up..." +} +``` + +For threaded replies, pass `parent_id` as well. + +### Search + +``` +lemmy_search { "q": "climate", "type_": "Posts" } +lemmy_search { "q": "rust", "type_": "Communities" } +``` + +## Moderation + +Lemmy's moderation is layered: **community mods** handle their community; **instance admins** handle everything on the local server. This bundle's moderation verbs operate at the instance-admin level (assuming `LEMMY_JWT` belongs to an admin). + +- **Inline (rate-limited, fires immediately):** `lemmy_block_user`, `lemmy_block_community` (user-scoped; hides content from your view). +- **Queued (operator confirms in Nest within 72h):** `lemmy_block_instance`, `lemmy_defederate` (instance-wide; admin-only). +- **Read-only:** `lemmy_review_reports` lists open post + comment reports for mods/admins. +- **Media management:** `lemmy_media_prune { "older_than_days": 7, "confirm": "yes" }` triggers pict-rs to drop remote cached images older than N days. + +## Cross-app notes + +- **Posting Lemmy links from other fediverse apps**: Mastodon/GoToSocial users can boost Lemmy posts by pasting the post URL — federation translates this into a share event. Lemmy's comment threads remain authoritative; boosts don't pull comments back. +- **Blog cross-posting**: a WriteFreely post can be submitted to a Lemmy community as a link post (`lemmy_post` with `url` set to the WF post URL). Full-text cross-post (text post body) lands with F.12. + +## Troubleshooting + +- **"Cannot reach Lemmy"** — `docker ps | grep crow-lemmy`. First-boot migrations take 60+ seconds; the healthcheck has a 60s start period. +- **Federation not working** — check `LEMMY_FEDERATION_ENABLED` is true (default). Also verify `federation.enabled` is true in `/config/config.hjson` inside the container (`docker exec crow-lemmy cat /config/config.hjson`). +- **Images not loading** — pict-rs container health check. `docker logs crow-lemmy-pictrs` and verify `LEMMY_PICTRS_API_KEY` matches between the two containers. +- **Disk filling fast** — federated community caching. Run `lemmy_media_prune` and reduce follow count on heavy communities. +- **Admin endpoints returning 404** — your `LEMMY_JWT` may belong to a regular user rather than admin. Verify in the web UI's **Admin** dropdown — if absent, log out, promote the account via DB, and re-login. diff --git a/registry/add-ons.json b/registry/add-ons.json index 087b36b..52acb43 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -3238,6 +3238,46 @@ "webUI": null, "notes": "Four containers (app + horizon + postgres + redis). zknt/pixelfed image runs nginx+PHP-FPM under supervisord. Expose via caddy_add_federation_site { domain: PIXELFED_HOSTNAME, upstream: 'pixelfed:80', profile: 'activitypub' }. Admin user via `docker exec -it crow-pixelfed php artisan user:create`." }, + { + "id": "lemmy", + "name": "Lemmy", + "description": "Federated link aggregator and discussion platform over ActivityPub — Reddit-alternative. Posts, comments, communities, votes; federates with Lemmy/Kbin/Mastodon.", + "type": "bundle", + "version": "1.0.0", + "author": "Crow", + "category": "federated-social", + "tags": ["lemmy", "activitypub", "fediverse", "link-aggregator", "discussion", "reddit-alt"], + "icon": "message-circle", + "docker": { "composefile": "docker-compose.yml" }, + "server": { + "command": "node", + "args": ["server/index.js"], + "envKeys": ["LEMMY_URL", "LEMMY_JWT", "LEMMY_HOSTNAME"] + }, + "panel": "panel/lemmy.js", + "panelRoutes": "panel/routes.js", + "skills": ["skills/lemmy.md"], + "consent_required": true, + "requires": { + "env": ["LEMMY_HOSTNAME", "LEMMY_DB_PASSWORD", "LEMMY_PICTRS_API_KEY"], + "bundles": ["caddy"], + "min_ram_mb": 1000, + "recommended_ram_mb": 2000, + "min_disk_mb": 5000, + "recommended_disk_mb": 50000 + }, + "env_vars": [ + { "name": "LEMMY_HOSTNAME", "description": "Public domain (subdomain).", "required": true }, + { "name": "LEMMY_DB_PASSWORD", "description": "Password for the bundled Postgres role.", "required": true, "secret": true }, + { "name": "LEMMY_PICTRS_API_KEY", "description": "Shared secret between Lemmy and pict-rs (any random 32+ chars).", "required": true, "secret": true }, + { "name": "LEMMY_JWT", "description": "Admin JWT (obtain via POST /api/v3/user/login after setup wizard).", "required": false, "secret": true }, + { "name": "LEMMY_OPEN_REGISTRATION", "description": "Allow new user signups.", "default": "false", "required": false }, + { "name": "LEMMY_FEDERATION_ENABLED", "description": "Federate with other ActivityPub servers.", "default": "true", "required": false } + ], + "ports": [], + "webUI": null, + "notes": "Four containers (lemmy + lemmy-ui + postgres + pict-rs). Expose via caddy_add_federation_site { domain: LEMMY_HOSTNAME, upstream: 'lemmy-ui:1234', profile: 'activitypub' }. Admin registers via the web setup wizard on first boot." + }, { "id": "developer-kit", "name": "Developer Kit", diff --git a/skills/superpowers.md b/skills/superpowers.md index 00972f1..4a6aeca 100644 --- a/skills/superpowers.md +++ b/skills/superpowers.md @@ -84,6 +84,7 @@ This is the master routing skill. Consult this **before every task** to determin | "matrix", "dendrite", "join #room:server", "send @user:server", "e2ee chat", "matrix room" | "matrix", "dendrite", "unirse a #sala:servidor", "mensaje a @usuario:servidor", "chat e2ee" | matrix-dendrite | crow-matrix-dendrite | | "funkwhale", "federated music", "upload track", "follow channel", "music library", "fediverse audio", "podcast", "playlist" | "funkwhale", "música federada", "subir pista", "seguir canal", "biblioteca musical", "audio fediverso", "podcast", "lista de reproducción" | funkwhale | crow-funkwhale | | "pixelfed", "post photo", "share picture", "photo feed", "fediverse photo", "instagram alternative" | "pixelfed", "publicar foto", "compartir imagen", "feed de fotos", "foto fediverso", "alternativa instagram" | pixelfed | crow-pixelfed | +| "lemmy", "link aggregator", "reddit alternative", "subscribe community", "post link", "fediverse discussion", "upvote" | "lemmy", "agregador enlaces", "alternativa reddit", "suscribir comunidad", "publicar enlace", "discusión fediverso" | lemmy | crow-lemmy | | "tutor me", "teach me", "quiz me", "help me understand" | "enséñame", "explícame", "evalúame" | tutoring | crow-memory | | "wrap up", "summarize session", "what did we do" | "resumir sesión", "qué hicimos" | session-summary | crow-memory | | "change language", "speak in..." | "cambiar idioma", "háblame en..." | i18n | crow-memory |