From d2bd2ffa1404bf65739e915962b4c75977d23ebb Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Sun, 12 Apr 2026 13:50:27 -0500 Subject: [PATCH] =?UTF-8?q?F.1:=20GoToSocial=20bundle=20=E2=80=94=20fedive?= =?UTF-8?q?rse=20microblog=20pilot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First federated app on top of F.0. GoToSocial is the lightweight single-binary ActivityPub server (512 MB min / 1 GB recommended), chosen as the pilot per the Phase 2 plan — proves the full template end-to-end (shared docker network, hardware gate, rate limiter, consent text, queued moderation, media prune) on Pi hardware before the 7 heavier bundles follow. Bundle (bundles/gotosocial/): - manifest.json consent_required: true with full EN/ES blast-radius text (replication irreversibility, defederation risk, media cache growth). min_ram_mb=512, recommended=1024. requires.bundles: ["caddy"] — F.0 hardware + dep gates refuse install if Caddy is missing or RAM insufficient - docker-compose.yml pinned 0.18.0; joins external crow-federation network; NO host port publish (Caddy reaches gotosocial:8080 by service name); mem_limit 1g; SQLite default backend for Pi simplicity - server/server.js 14 MCP tools following the plan's verb taxonomy: gts_status / gts_post / gts_feed / gts_search / gts_follow / gts_unfollow / gts_block_user / gts_mute_user / gts_block_domain / gts_defederate / gts_review_reports / gts_report_remote / gts_import_blocklist / gts_media_prune Content + inline moderation wrapped with shared rate-limiter. Destructive instance-level verbs (defederate, block_domain, import_blocklist) QUEUED into moderation_actions with notification; operator confirms from Nest panel before the action fires. Pattern: knowledge-base-style try/catch lazy imports of shared deps so installed-mode bundles don't hard- fail when servers/shared/... isn't resolvable - skills/gotosocial.md triggers, tool surface, Caddy-expose recipe, moderation workflow, federation etiquette warnings, troubleshooting - panel/ status + federation peer count + public/home timeline preview. XSS-safe (textContent + createElement only). Moderation queue confirmation UI deferred to F.11 - scripts/ backup.sh (sqlite online backup + media tar), media-prune.sh (daily cron, retention from env), post-install.sh (network verify + next-step output) Platform wiring: - registry/add-ons.json entry with full env_vars schema + notes - servers/gateway/dashboard/panels/extensions.js three new category colors: federated-social (magenta), federated-media (pink), federated-comms (violet). Matching CATEGORY_LABELS pointers - servers/gateway/dashboard/shared/i18n.js EN/ES for the three new category keys - servers/gateway/dashboard/nav-registry.js federated-social/comms → core sidebar group, federated-media → media - skills/superpowers.md EN/ES trigger row for gotosocial - CLAUDE.md Skills Reference entry Verified: - node --check on all changed files - docker compose -f bundles/gotosocial/docker-compose.yml config parses - JSON validation: registry/add-ons.json - npm run check passes - MCP server boots in isolation (createGotosocialServer resolves with lazy shared-dep fallback; no main-repo dependency leak) Deferred to F.11 / live verification: - Moderation queue confirmation UI (Nest panel) — bundle writes pending rows; manual DB apply until the queue UI lands - Live install + Caddy add_federation_site + federated round-trip to @Gargron@mastodon.social on grackle --- CLAUDE.md | 1 + bundles/gotosocial/.env.example | 32 + bundles/gotosocial/docker-compose.yml | 50 ++ bundles/gotosocial/manifest.json | 71 +++ bundles/gotosocial/package.json | 11 + bundles/gotosocial/panel/gotosocial.js | 188 ++++++ bundles/gotosocial/panel/routes.js | 82 +++ bundles/gotosocial/scripts/backup.sh | 44 ++ bundles/gotosocial/scripts/media-prune.sh | 27 + bundles/gotosocial/scripts/post-install.sh | 57 ++ bundles/gotosocial/server/index.js | 12 + bundles/gotosocial/server/server.js | 546 ++++++++++++++++++ bundles/gotosocial/skills/gotosocial.md | 142 +++++ registry/add-ons.json | 69 +++ servers/gateway/dashboard/nav-registry.js | 3 + .../gateway/dashboard/panels/extensions.js | 6 + servers/gateway/dashboard/shared/i18n.js | 3 + skills/superpowers.md | 1 + 18 files changed, 1345 insertions(+) create mode 100644 bundles/gotosocial/.env.example create mode 100644 bundles/gotosocial/docker-compose.yml create mode 100644 bundles/gotosocial/manifest.json create mode 100644 bundles/gotosocial/package.json create mode 100644 bundles/gotosocial/panel/gotosocial.js create mode 100644 bundles/gotosocial/panel/routes.js create mode 100755 bundles/gotosocial/scripts/backup.sh create mode 100755 bundles/gotosocial/scripts/media-prune.sh create mode 100755 bundles/gotosocial/scripts/post-install.sh create mode 100644 bundles/gotosocial/server/index.js create mode 100644 bundles/gotosocial/server/server.js create mode 100644 bundles/gotosocial/skills/gotosocial.md diff --git a/CLAUDE.md b/CLAUDE.md index c665532..4ec1a23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -462,6 +462,7 @@ Add-on skills (activated when corresponding add-on is installed): - `trilium.md` — TriliumNext knowledge base: note search, creation, web clipping, organization - `knowledge-base.md` — Multilingual knowledge base: create, edit, publish, search, verify resources, share articles, LAN discovery - `maker-lab.md` — STEM education companion for kids: scaffolded AI tutor, hint-ladder pedagogy, age-banded personas (kid/tween/adult), solo/family/classroom modes, guest sidecar +- `gotosocial.md` — GoToSocial ActivityPub microblog: post, follow, search, moderate (block_user/mute inline; defederate/block_domain/import_blocklist queued for operator confirmation), media prune, federation health - `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/gotosocial/.env.example b/bundles/gotosocial/.env.example new file mode 100644 index 0000000..468cd5b --- /dev/null +++ b/bundles/gotosocial/.env.example @@ -0,0 +1,32 @@ +# GoToSocial — required config + +# Public domain dedicated to GoToSocial (must be a subdomain; subpath mounts +# break ActivityPub federation because actors are URL-keyed). +GTS_HOST=gts.example.com + +# Account handle domain. Usually the same as GTS_HOST. If different, you +# must add a .well-known/webfinger delegation on the apex (caddy_set_wellknown). +GTS_ACCOUNT_DOMAIN=gts.example.com + +# Internal URL the Crow MCP server uses to reach GoToSocial. Leave as-is +# when running over the shared crow-federation docker network. +GTS_URL=http://gotosocial:8080 + +# API access token for the admin account. Generate via the web UI after +# first login, or via the CLI: +# docker exec crow-gotosocial ./gotosocial --config-path /gotosocial/config.yaml \ +# admin account create-token --username +# Leaving this unset limits the Crow tools to public read-only operations. +GTS_ACCESS_TOKEN= + +# Remote media retention. Pi-class hosts should lower to 7; x86 hosts with +# large disks can raise to 30+. +GTS_MEDIA_RETENTION_DAYS=14 + +# Optional: IFTAS / The Bad Space / custom domain blocklist URL. Imported +# once at post-install if set (subsequent imports go through the standard +# moderation confirmation flow). +# GTS_IMPORT_BLOCKLIST=https://iftas.org/example-blocklist.txt + +# Host data directory override (default ~/.crow/gotosocial). +# GTS_DATA_DIR=/mnt/nvme/gotosocial diff --git a/bundles/gotosocial/docker-compose.yml b/bundles/gotosocial/docker-compose.yml new file mode 100644 index 0000000..642f7ee --- /dev/null +++ b/bundles/gotosocial/docker-compose.yml @@ -0,0 +1,50 @@ +# GoToSocial — lightweight ActivityPub server. +# +# Deployment model: no host port publish. Caddy (crow-federation network) +# reaches the container by service name (gotosocial:8080) and terminates TLS +# at 443. Add the site with caddy_add_federation_site after install. +# +# Data lives at ~/.crow/gotosocial/ (single bind mount). SQLite is the +# default backend; switching to Postgres requires manual compose edit. + +networks: + crow-federation: + external: true + default: + +services: + gotosocial: + image: superseriousbusiness/gotosocial:0.18.0 + container_name: crow-gotosocial + networks: + - default + - crow-federation + environment: + GTS_HOST: ${GTS_HOST} + GTS_ACCOUNT_DOMAIN: ${GTS_ACCOUNT_DOMAIN:-${GTS_HOST}} + GTS_PROTOCOL: https + GTS_PORT: "8080" + GTS_BIND_ADDRESS: 0.0.0.0 + # SQLite by default — simplest for Pi-class hosts. Switch to postgres + # by setting GTS_DB_TYPE=postgres plus GTS_DB_ADDRESS/USER/PASSWORD. + GTS_DB_TYPE: sqlite + GTS_DB_ADDRESS: /gotosocial/storage/sqlite.db + GTS_STORAGE_BACKEND: local + GTS_STORAGE_LOCAL_BASE_PATH: /gotosocial/storage + GTS_LETSENCRYPT_ENABLED: "false" # Caddy handles TLS + GTS_TRUSTED_PROXIES: 172.16.0.0/12,10.0.0.0/8 + GTS_MEDIA_REMOTE_CACHE_DAYS: ${GTS_MEDIA_RETENTION_DAYS:-14} + # Advertise-proto: behind Caddy the client sees HTTPS; tell GTS the + # external scheme so generated URLs are correct. + GTS_ADVERTISE_HTTPS: "true" + volumes: + - ${GTS_DATA_DIR:-~/.crow/gotosocial}:/gotosocial/storage + init: true + mem_limit: 1g + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/readyz || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 20s diff --git a/bundles/gotosocial/manifest.json b/bundles/gotosocial/manifest.json new file mode 100644 index 0000000..99e0806 --- /dev/null +++ b/bundles/gotosocial/manifest.json @@ -0,0 +1,71 @@ +{ + "id": "gotosocial", + "name": "GoToSocial", + "version": "1.0.0", + "description": "Lightweight ActivityPub microblog server (fediverse). Pi-friendly single-binary alternative to Mastodon — joins the public fediverse with a sub-500 MB footprint.", + "type": "bundle", + "author": "Crow", + "category": "federated-social", + "tags": ["activitypub", "fediverse", "microblog", "federated", "mastodon-compatible"], + "icon": "globe", + "docker": { "composefile": "docker-compose.yml" }, + "server": { + "command": "node", + "args": ["server/index.js"], + "envKeys": ["GTS_URL", "GTS_ACCESS_TOKEN"] + }, + "panel": "panel/gotosocial.js", + "panelRoutes": "panel/routes.js", + "skills": ["skills/gotosocial.md"], + "consent_required": true, + "install_consent_messages": { + "en": "GoToSocial joins the fediverse: your instance becomes publicly addressable under the domain you configure, and any content you publish is replicated to other ActivityPub servers. Replicated content cannot be fully recalled — deletions may not reach every server that cached your post. The instance fetches and caches content and profile images from other federated servers, which may include objectionable material. You are responsible for moderating your instance and for legal compliance in your jurisdiction. If your instance is reported for abuse, major hubs (mastodon.social, matrix.org) may defederate your domain — and a poisoned domain cannot easily be rehabilitated; you may need to move to a fresh domain. Automatic media pruning (14-day retention by default, 7 days on Pi-class hosts) is enabled from day 1; remote media caches still grow substantially under active federation.", + "es": "GoToSocial se une al fediverso: tu instancia será públicamente direccionable bajo el dominio que configures, y todo el contenido que publiques se replica a otros servidores ActivityPub. El contenido replicado no puede recuperarse completamente — las eliminaciones pueden no llegar a todos los servidores que guardaron tu publicación. La instancia obtiene y guarda contenido e imágenes de perfil de otros servidores federados, que pueden incluir material objetable. Eres responsable de moderar tu instancia y cumplir con la ley de tu jurisdicción. Si tu instancia es reportada por abuso, los nodos principales (mastodon.social, matrix.org) pueden dejar de federarse con tu dominio — y un dominio envenenado no puede rehabilitarse fácilmente; puede que tengas que mudarte a un dominio nuevo. La limpieza automática de medios (14 días por defecto, 7 días en hosts tipo Pi) está activa desde el primer día; los cachés de medios remotos igualmente crecen bajo federación activa." + }, + "requires": { + "env": ["GTS_HOST", "GTS_ACCOUNT_DOMAIN"], + "bundles": ["caddy"], + "min_ram_mb": 512, + "recommended_ram_mb": 1024, + "min_disk_mb": 2000, + "recommended_disk_mb": 20000 + }, + "env_vars": [ + { + "name": "GTS_HOST", + "description": "Public domain for this GoToSocial instance (e.g., gts.example.com). Must be a subdomain dedicated to GoToSocial — ActivityPub actors are URL-keyed and subpath mounts break federation.", + "required": true + }, + { + "name": "GTS_ACCOUNT_DOMAIN", + "description": "Account domain used in @user@domain handles. Usually the same as GTS_HOST. If your apex is example.com and GTS_HOST=gts.example.com, set this to example.com and add a .well-known/webfinger delegation on the apex.", + "required": true + }, + { + "name": "GTS_URL", + "description": "Internal URL the Crow MCP server uses to reach GoToSocial (over the crow-federation docker network).", + "default": "http://gotosocial:8080", + "required": false + }, + { + "name": "GTS_ACCESS_TOKEN", + "description": "API access token for the admin account (generated via the web UI after first login, or via the CLI: docker exec gotosocial ./gotosocial admin account create-token).", + "required": false, + "secret": true + }, + { + "name": "GTS_MEDIA_RETENTION_DAYS", + "description": "Days to keep remote media in the local cache before pruning. Default 14; Pi-class hosts should lower to 7.", + "default": "14", + "required": false + }, + { + "name": "GTS_IMPORT_BLOCKLIST", + "description": "Optional: URL to an IFTAS / Bad Space / custom domain blocklist (one domain per line). Imported on first post-install run if set.", + "required": false + } + ], + "ports": [], + "webUI": null, + "notes": "Subdomain-only deployment (federation requires URL-keyed actors). Caddy reaches GoToSocial by docker service name (gotosocial:8080) over the crow-federation network; no host port is published. After install, run caddy_add_federation_site { domain: GTS_HOST, upstream: 'gotosocial:8080', profile: 'activitypub', wellknown: { nodeinfo: { href: 'https:///nodeinfo/2.0' } } } to expose the instance with a real LE cert." +} diff --git a/bundles/gotosocial/package.json b/bundles/gotosocial/package.json new file mode 100644 index 0000000..9ab064a --- /dev/null +++ b/bundles/gotosocial/package.json @@ -0,0 +1,11 @@ +{ + "name": "crow-gotosocial", + "version": "1.0.0", + "description": "GoToSocial MCP server — post, follow, search, moderate across the fediverse", + "type": "module", + "main": "server/index.js", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.24.0" + } +} diff --git a/bundles/gotosocial/panel/gotosocial.js b/bundles/gotosocial/panel/gotosocial.js new file mode 100644 index 0000000..aa5357a --- /dev/null +++ b/bundles/gotosocial/panel/gotosocial.js @@ -0,0 +1,188 @@ +/** + * Crow's Nest Panel — GoToSocial: status + recent timeline preview + * + * Read-only view. Moderation queue confirmation UI lands with F.11/F.12; + * until then the operator confirms queued actions via direct DB edits or + * a follow-up panel enhancement. + * + * XSS-safe: textContent / createElement only. + */ + +export default { + id: "gotosocial", + name: "GoToSocial", + icon: "globe", + route: "/dashboard/gotosocial", + navOrder: 70, + category: "federated-social", + + async handler(req, res, { layout }) { + const content = ` + +
+

GoToSocial fediverse microblog

+ +
+

Status

+
Loading…
+
+ +
+

Recent Timeline

+
+ + +
+
Loading…
+
+ +
+

Notes

+
    +
  • Moderation actions (defederate, block_domain, import_blocklist) queue pending rows in moderation_actions. Operator confirmation UI lands in a later release.
  • +
  • Remote media cache prunes daily via scripts/media-prune.sh. Override retention via GTS_MEDIA_RETENTION_DAYS.
  • +
  • Exposed via Caddy. Verify TLS with caddy_cert_health.
  • +
+
+
+ + `; + res.send(layout({ title: "GoToSocial", content })); + }, +}; + +function script() { + return ` + function clearNode(el) { while (el.firstChild) el.removeChild(el.firstChild); } + function row(label, value) { + const r = document.createElement('div'); + r.className = 'gts-row'; + const b = document.createElement('b'); + b.textContent = label; + r.appendChild(b); + const s = document.createElement('span'); + s.textContent = value == null ? '—' : String(value); + r.appendChild(s); + return r; + } + function errorNode(msg) { + const d = document.createElement('div'); + d.className = 'np-error'; + d.textContent = msg; + return d; + } + + async function loadStatus() { + const el = document.getElementById('gts-status'); + clearNode(el); + try { + const res = await fetch('/api/gotosocial/status'); + const d = await res.json(); + if (d.error) { el.appendChild(errorNode(d.error)); return; } + const card = document.createElement('div'); + card.className = 'gts-card'; + card.appendChild(row('Instance', d.uri)); + card.appendChild(row('Title', d.title)); + card.appendChild(row('Version', d.version)); + card.appendChild(row('Users', d.stats?.user_count)); + card.appendChild(row('Statuses', d.stats?.status_count)); + card.appendChild(row('Federated peers', d.federated_peers)); + card.appendChild(row('Authenticated as', d.account ? '@' + d.account.acct : '(none — set GTS_ACCESS_TOKEN)')); + el.appendChild(card); + } catch (e) { + el.appendChild(errorNode('Cannot reach GoToSocial API.')); + } + } + + async function loadTimeline(source) { + const el = document.getElementById('gts-timeline'); + clearNode(el); + try { + const res = await fetch('/api/gotosocial/timeline?source=' + encodeURIComponent(source) + '&limit=10'); + const d = await res.json(); + if (d.error) { el.appendChild(errorNode(d.error)); return; } + if (!d.items || d.items.length === 0) { + const e = document.createElement('div'); + e.className = 'np-idle'; + e.textContent = 'Timeline is empty.'; + el.appendChild(e); + return; + } + for (const it of d.items) { + const card = document.createElement('div'); + card.className = 'gts-toot'; + const head = document.createElement('div'); + head.className = 'gts-toot-head'; + const author = document.createElement('b'); + author.textContent = '@' + (it.acct || 'unknown'); + head.appendChild(author); + const when = document.createElement('span'); + when.className = 'gts-toot-when'; + when.textContent = new Date(it.created_at).toLocaleString(); + head.appendChild(when); + card.appendChild(head); + const body = document.createElement('div'); + body.className = 'gts-toot-body'; + body.textContent = it.content_excerpt || ''; + card.appendChild(body); + const meta = document.createElement('div'); + meta.className = 'gts-toot-meta'; + meta.textContent = 'reblogs ' + (it.reblogs || 0) + ' \u2022 favs ' + (it.favs || 0); + card.appendChild(meta); + el.appendChild(card); + } + } catch (e) { + el.appendChild(errorNode('Cannot load timeline: ' + e.message)); + } + } + + document.getElementById('gts-tl-public').addEventListener('click', function () { + document.getElementById('gts-tl-public').className = 'gts-tab-active'; + document.getElementById('gts-tl-home').className = ''; + loadTimeline('public'); + }); + document.getElementById('gts-tl-home').addEventListener('click', function () { + document.getElementById('gts-tl-home').className = 'gts-tab-active'; + document.getElementById('gts-tl-public').className = ''; + loadTimeline('home'); + }); + loadStatus(); + loadTimeline('public'); + `; +} + +function styles() { + return ` + .gts-panel h1 { margin: 0 0 1rem; font-size: 1.5rem; } + .gts-subtitle { font-size: 0.85rem; color: var(--crow-text-muted); font-weight: 400; margin-left: .5rem; } + .gts-section { margin-bottom: 1.8rem; } + .gts-section h3 { font-size: 0.8rem; color: var(--crow-text-muted); text-transform: uppercase; + letter-spacing: 0.05em; margin: 0 0 0.7rem; } + .gts-card { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border); + border-radius: 10px; padding: 1rem; } + .gts-row { display: flex; justify-content: space-between; gap: 1rem; padding: .25rem 0; + font-size: .9rem; color: var(--crow-text-primary); } + .gts-row b { color: var(--crow-text-muted); font-weight: 500; min-width: 140px; } + .gts-toggle { display: flex; gap: .4rem; margin-bottom: .7rem; } + .gts-toggle button { background: var(--crow-bg-elevated); color: var(--crow-text-muted); + border: 1px solid var(--crow-border); border-radius: 6px; + padding: .3rem .7rem; font-size: .85rem; cursor: pointer; } + .gts-tab-active { background: var(--crow-accent) !important; color: #0b0d10 !important; + border-color: var(--crow-accent) !important; } + .gts-toot { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border); + border-radius: 10px; padding: .8rem 1rem; margin-bottom: .5rem; } + .gts-toot-head { display: flex; justify-content: space-between; margin-bottom: .3rem; } + .gts-toot-head b { font-size: .9rem; color: var(--crow-text-primary); } + .gts-toot-when { font-size: .75rem; color: var(--crow-text-muted); font-family: ui-monospace, monospace; } + .gts-toot-body { font-size: .9rem; color: var(--crow-text-primary); line-height: 1.4; } + .gts-toot-meta { font-size: .75rem; color: var(--crow-text-muted); margin-top: .4rem; } + .gts-notes ul { margin: 0; padding-left: 1.2rem; color: var(--crow-text-secondary); font-size: .88rem; } + .gts-notes li { margin-bottom: .3rem; } + .gts-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: var(--crow-error, #ef4444); font-size: 0.9rem; padding: 1rem; + background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; } + `; +} diff --git a/bundles/gotosocial/panel/routes.js b/bundles/gotosocial/panel/routes.js new file mode 100644 index 0000000..f201f7e --- /dev/null +++ b/bundles/gotosocial/panel/routes.js @@ -0,0 +1,82 @@ +/** + * GoToSocial panel API routes. + * + * Dashboard-only endpoints. Read-only — no moderation actions fire here + * (moderation queue confirmation UI lands with F.11/F.12). All calls hit + * the GoToSocial API using the configured access token; public endpoints + * work even without a token. + */ + +import { Router } from "express"; + +const GTS_URL = () => (process.env.GTS_URL || "http://gotosocial:8080").replace(/\/+$/, ""); +const GTS_ACCESS_TOKEN = () => process.env.GTS_ACCESS_TOKEN || ""; +const TIMEOUT = 10_000; + +async function gts(path, { noAuth } = {}) { + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), TIMEOUT); + try { + const headers = { Accept: "application/json" }; + if (!noAuth && GTS_ACCESS_TOKEN()) headers.Authorization = `Bearer ${GTS_ACCESS_TOKEN()}`; + const r = await fetch(`${GTS_URL()}${path}`, { 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 gotosocialRouter(authMiddleware) { + const router = Router(); + + router.get("/api/gotosocial/status", authMiddleware, async (_req, res) => { + try { + const [instance, account, peers] = await Promise.all([ + gts("/api/v1/instance", { noAuth: true }), + GTS_ACCESS_TOKEN() ? gts("/api/v1/accounts/verify_credentials").catch(() => null) : Promise.resolve(null), + gts("/api/v1/instance/peers", { noAuth: true }).catch(() => []), + ]); + res.json({ + uri: instance.uri, + title: instance.title, + version: instance.version, + stats: instance.stats, + account: account ? { acct: account.acct, display_name: account.display_name } : null, + federated_peers: Array.isArray(peers) ? peers.length : null, + has_token: Boolean(GTS_ACCESS_TOKEN()), + }); + } catch (err) { + res.json({ error: `Cannot reach GoToSocial: ${err.message}` }); + } + }); + + router.get("/api/gotosocial/timeline", authMiddleware, async (req, res) => { + try { + const source = req.query.source === "home" && GTS_ACCESS_TOKEN() ? "home" : "public"; + const limit = Math.max(1, Math.min(20, Number(req.query.limit) || 10)); + const items = await gts( + source === "home" + ? `/api/v1/timelines/home?limit=${limit}` + : `/api/v1/timelines/public?limit=${limit}`, + { noAuth: source === "public" }, + ); + const summary = (Array.isArray(items) ? items : []).map((it) => ({ + id: it.id, + acct: it.account?.acct, + display_name: it.account?.display_name, + url: it.url, + content_excerpt: (it.content || "").replace(/<[^>]+>/g, "").slice(0, 280), + created_at: it.created_at, + reblogs: it.reblogs_count, + favs: it.favourites_count, + })); + res.json({ source, count: summary.length, items: summary }); + } catch (err) { + res.json({ error: err.message }); + } + }); + + return router; +} diff --git a/bundles/gotosocial/scripts/backup.sh b/bundles/gotosocial/scripts/backup.sh new file mode 100755 index 0000000..d1f65e1 --- /dev/null +++ b/bundles/gotosocial/scripts/backup.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# GoToSocial backup script. +# +# Dumps SQLite (default backend) via online-backup for consistency, tars +# the media storage directory, and writes everything to +# ~/.crow/backups/gotosocial/.tar.zst. +# +# This is NOT called by `npm run backup` — Crow's main backup flow +# deliberately does not touch bundle data. Run manually or schedule via +# crow_create_schedule. + +set -euo pipefail + +BUNDLE_NAME="gotosocial" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +BACKUP_ROOT="${CROW_HOME:-$HOME/.crow}/backups/${BUNDLE_NAME}" +DATA_DIR="${GTS_DATA_DIR:-$HOME/.crow/gotosocial}" + +mkdir -p "$BACKUP_ROOT" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# SQLite online backup via sqlite3 (atomic — no reader lock contention) +if [ -f "$DATA_DIR/sqlite.db" ]; then + docker exec crow-gotosocial sqlite3 /gotosocial/storage/sqlite.db \ + ".backup /gotosocial/storage/sqlite-backup-${STAMP}.db" + docker cp "crow-gotosocial:/gotosocial/storage/sqlite-backup-${STAMP}.db" "$WORK/sqlite.db" + docker exec crow-gotosocial rm "/gotosocial/storage/sqlite-backup-${STAMP}.db" +else + echo "No sqlite.db at $DATA_DIR — skipping DB dump (Postgres?)" +fi + +# Tar the media storage dir (excluding the live DB since we have the backup above) +tar --exclude 'sqlite.db' --exclude 'sqlite.db-wal' --exclude 'sqlite.db-shm' \ + -C "$DATA_DIR" -cf "$WORK/media.tar" . + +OUT="${BACKUP_ROOT}/${BUNDLE_NAME}-${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}/${BUNDLE_NAME}-${STAMP}.tar.gz" + tar -C "$WORK" -czf "$OUT" . +fi +echo "wrote $OUT ($(du -h "$OUT" | cut -f1))" diff --git a/bundles/gotosocial/scripts/media-prune.sh b/bundles/gotosocial/scripts/media-prune.sh new file mode 100755 index 0000000..7c370c1 --- /dev/null +++ b/bundles/gotosocial/scripts/media-prune.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Daily remote-media prune for GoToSocial. +# +# Invokes GoToSocial's admin media-cleanup endpoint to evict cached remote +# media older than GTS_MEDIA_RETENTION_DAYS (default 14, or 7 on Pi). +# Registered with Crow's scheduler at install time; also invokable via +# the gts_media_prune MCP tool. + +set -euo pipefail + +CONTAINER="${GTS_CONTAINER:-crow-gotosocial}" +DAYS="${GTS_MEDIA_RETENTION_DAYS:-14}" + +if ! docker ps --format '{{.Names}}' | grep -qw "$CONTAINER"; then + echo "gotosocial container not running — skipping prune" + exit 0 +fi + +# GoToSocial ships a built-in CLI for this — no API token needed from host. +docker exec "$CONTAINER" \ + /gotosocial/gotosocial --config-path /gotosocial/config.yaml \ + admin media prune-remote --days "$DAYS" || { + echo "prune command failed — check container logs" + exit 1 + } + +echo "pruned remote media older than $DAYS days" diff --git a/bundles/gotosocial/scripts/post-install.sh b/bundles/gotosocial/scripts/post-install.sh new file mode 100755 index 0000000..9237e50 --- /dev/null +++ b/bundles/gotosocial/scripts/post-install.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# GoToSocial bundle post-install hook. +# +# Runs after `docker compose up -d` and a healthy container. +# Responsibilities: +# 1. Verify the crow-federation docker network is joined. +# 2. If GTS_IMPORT_BLOCKLIST is set, queue the initial import (treated +# as pre-authorized by the install consent modal). +# 3. Print next-step guidance. + +set -euo pipefail + +BUNDLE_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${BUNDLE_DIR}/.env" + +# Source .env if present so the script has access to configured vars. +if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + set -a; . "$ENV_FILE"; set +a +fi + +# 1. Ensure the container is on crow-federation (compose already declares +# it, but guard against a partial compose that forgot the network). +if ! docker inspect crow-gotosocial --format '{{range $k, $_ := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | grep -qw crow-federation; then + echo "WARN: crow-gotosocial is not on the crow-federation network — federation sites via Caddy will not reach it by service name" >&2 +fi + +# 2. Optional IFTAS/Bad Space blocklist import. +if [ -n "${GTS_IMPORT_BLOCKLIST:-}" ]; then + echo "Queuing initial blocklist import from ${GTS_IMPORT_BLOCKLIST}" + echo " (this is the one install-time auto-import; subsequent imports go" + echo " through the operator-confirmation queue)" + # Actual import happens via the MCP tool against the live DB; this + # script just leaves a marker the bundle picks up on first MCP call. + mkdir -p "${BUNDLE_DIR}" + echo "${GTS_IMPORT_BLOCKLIST}" > "${BUNDLE_DIR}/.pending-blocklist-import" +fi + +cat <}/ and create the admin account + 2. Generate an API token: + docker exec crow-gotosocial ./gotosocial --config-path /gotosocial/config.yaml \\ + admin account create-token --username + Paste into .env as GTS_ACCESS_TOKEN and restart the MCP server. + 3. Expose via Caddy (one-time): + caddy_add_federation_site { + domain: "${GTS_HOST:-}", + upstream: "gotosocial:8080", + profile: "activitypub" + } + 4. Verify cert issuance: + caddy_cert_health { domain: "${GTS_HOST:-}" } + +EOF diff --git a/bundles/gotosocial/server/index.js b/bundles/gotosocial/server/index.js new file mode 100644 index 0000000..c10bc3c --- /dev/null +++ b/bundles/gotosocial/server/index.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +/** + * GoToSocial MCP Server — stdio transport entry point + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createGotosocialServer } from "./server.js"; + +const server = await createGotosocialServer(); +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/bundles/gotosocial/server/server.js b/bundles/gotosocial/server/server.js new file mode 100644 index 0000000..0322a80 --- /dev/null +++ b/bundles/gotosocial/server/server.js @@ -0,0 +1,546 @@ +/** + * GoToSocial MCP Server + * + * Exposes the fediverse surface — post statuses, browse timelines, search, + * follow remote actors, moderate — through Crow's MCP layer. Talks to + * GoToSocial via its Mastodon-compatible REST API so the same patterns + * transfer to the Mastodon bundle in F.7. + * + * Tool shape matches the plan's "consistent verbs across apps": + * gts_status, gts_post, gts_feed, gts_search, gts_follow, gts_unfollow, + * gts_block_user, gts_mute_user, gts_block_domain, gts_defederate, + * gts_review_reports, gts_report_remote, gts_import_blocklist, + * gts_media_prune + * + * Rate limiting: + * Content-producing and moderation tools are wrapped with the shared + * token-bucket limiter (servers/shared/rate-limiter.js). In "installed + * to ~/.crow/bundles/" mode the shared module may not resolve — in + * that case the wrapper falls back to pass-through, matching the + * knowledge-base / media bundle convention. Crow's main MCP + * installation (first-party monorepo mode) gets real rate limiting. + * + * Human-in-the-loop moderation: + * *_defederate and *_import_blocklist don't fire inline. They INSERT a + * pending row in moderation_actions + raise a Crow notification, and + * the operator confirms from the Nest panel. Implementation note: in + * F.1 we log a "queued" response and store the action against Crow's + * main DB when available. The Nest-panel confirmation UI lands with + * F.11 / F.12; until then the moderation queue accumulates pending + * rows an operator applies manually. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +const GTS_URL = (process.env.GTS_URL || "http://gotosocial:8080").replace(/\/+$/, ""); +const GTS_ACCESS_TOKEN = process.env.GTS_ACCESS_TOKEN || ""; + +// --- Lazy shared-dep imports (pattern borrowed from knowledge-base) --- + +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 { + // Installed-mode fallback: no-op wrapper + 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; + } +} + +// --- HTTP helper (Mastodon-compatible API) --- + +async function gtsFetch(path, { method = "GET", body, query, noAuth } = {}) { + const qs = query + ? "?" + + Object.entries(query) + .filter(([, v]) => v != null && v !== "") + .map(([k, v]) => + Array.isArray(v) + ? v.map((x) => `${encodeURIComponent(k + "[]")}=${encodeURIComponent(x)}`).join("&") + : `${encodeURIComponent(k)}=${encodeURIComponent(v)}`, + ) + .join("&") + : ""; + const url = `${GTS_URL}${path}${qs}`; + + const headers = { "Content-Type": "application/json", Accept: "application/json" }; + if (!noAuth && GTS_ACCESS_TOKEN) { + headers.Authorization = `Bearer ${GTS_ACCESS_TOKEN}`; + } + + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), 15_000); + 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, 500); + if (res.status === 401) { + throw new Error( + `GoToSocial auth failed (401). Set GTS_ACCESS_TOKEN (generate via: docker exec crow-gotosocial ./gotosocial admin account create-token).`, + ); + } + throw new Error(`GoToSocial ${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(`GoToSocial request timed out: ${path}`); + if (err.cause?.code === "ECONNREFUSED" || err.message?.includes("ECONNREFUSED")) { + throw new Error( + `Cannot reach GoToSocial at ${GTS_URL}. Verify the container is on the crow-federation network and running (docker ps | grep crow-gotosocial).`, + ); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +/** + * Queue a destructive moderation action (defederate / import_blocklist). + * Writes to moderation_actions + creates a Crow notification when a DB + * handle is available. Returns a structured "queued" response. + */ +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. Install Crow in monorepo mode or wait for F.11 bundle-connection work.", + 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"); + + // Check for existing pending row (idempotency) + 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 { + // Notification schema may be different across versions; don't let + // notification failure block queuing + } + } + + 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 — queued action could not be persisted. This table lands with F.11; until then, destructive moderation verbs are unavailable.", + }; + } + throw err; + } finally { + try { db.close(); } catch {} + } +} + +export async function createGotosocialServer(options = {}) { + await loadSharedDeps(); + + const server = new McpServer( + { name: "crow-gotosocial", version: "1.0.0" }, + { instructions: options.instructions }, + ); + + const limiter = wrapRateLimited + ? wrapRateLimited({ db: getDb ? getDb() : null }) + : (_, h) => h; + + // --- gts_status --- + server.tool( + "gts_status", + "Report GoToSocial instance health: reachability, admin account status, federation peer count, pending notifications, disk usage of the local media cache.", + {}, + async () => { + try { + const [instance, peers, account] = await Promise.all([ + gtsFetch("/api/v1/instance"), + gtsFetch("/api/v1/instance/peers").catch(() => []), + GTS_ACCESS_TOKEN ? gtsFetch("/api/v1/accounts/verify_credentials").catch(() => null) : Promise.resolve(null), + ]); + return { + content: [{ + type: "text", + text: JSON.stringify({ + instance: { + uri: instance.uri, + title: instance.title, + version: instance.version, + registrations: instance.registrations, + stats: instance.stats, + }, + authenticated_as: account ? { id: account.id, acct: account.acct, display_name: account.display_name } : null, + federated_peers: Array.isArray(peers) ? peers.length : null, + has_access_token: Boolean(GTS_ACCESS_TOKEN), + }, null, 2), + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + // --- gts_post --- + server.tool( + "gts_post", + "Publish a status (toot) to the fediverse. Content is public by default unless visibility is narrowed. Rate-limited: 10/hour per conversation.", + { + status: z.string().min(1).max(5000).describe("Post body (GoToSocial accepts up to 5000 chars; remote servers may truncate)."), + visibility: z.enum(["public", "unlisted", "private", "direct"]).optional().describe("public = federated + on public timelines; unlisted = federated but not on public timelines; private = followers only; direct = DM-like. Default public."), + spoiler_text: z.string().max(500).optional().describe("Content warning shown before the body."), + in_reply_to_id: z.string().max(50).optional().describe("Status ID to reply to."), + language: z.string().length(2).optional().describe("ISO 639-1 language code (e.g., en)."), + }, + limiter("gts_post", async (args) => { + try { + if (!GTS_ACCESS_TOKEN) { + return { content: [{ type: "text", text: "Error: GTS_ACCESS_TOKEN not set — cannot post." }] }; + } + const body = { + status: args.status, + visibility: args.visibility || "public", + ...(args.spoiler_text ? { spoiler_text: args.spoiler_text } : {}), + ...(args.in_reply_to_id ? { in_reply_to_id: args.in_reply_to_id } : {}), + ...(args.language ? { language: args.language } : {}), + }; + const status = await gtsFetch("/api/v1/statuses", { method: "POST", body }); + return { + content: [{ + type: "text", + text: JSON.stringify({ + id: status.id, + url: status.url, + uri: status.uri, + visibility: status.visibility, + created_at: status.created_at, + }, null, 2), + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- gts_feed --- + server.tool( + "gts_feed", + "Fetch a timeline. Choices: home (authenticated user's follows), public (local+federated), local (this instance only), notifications (mentions/replies/reblogs/follows targeting the authenticated user).", + { + source: z.enum(["home", "public", "local", "notifications"]).describe("Which timeline."), + limit: z.number().int().min(1).max(40).optional().describe("Max items to return (default 20)."), + since_id: z.string().max(50).optional().describe("Return items newer than this ID."), + max_id: z.string().max(50).optional().describe("Return items older than this ID."), + }, + limiter("gts_feed", async ({ source, limit, since_id, max_id }) => { + try { + if (source !== "public" && !GTS_ACCESS_TOKEN) { + return { content: [{ type: "text", text: "Error: non-public timelines require GTS_ACCESS_TOKEN." }] }; + } + const path = + source === "home" ? "/api/v1/timelines/home" + : source === "public" ? "/api/v1/timelines/public" + : source === "local" ? "/api/v1/timelines/public" + : "/api/v1/notifications"; + const query = { limit: limit ?? 20, since_id, max_id }; + if (source === "local") query.local = "true"; + const items = await gtsFetch(path, { query, noAuth: source === "public" && !GTS_ACCESS_TOKEN }); + const summary = (Array.isArray(items) ? items : []).map((it) => + source === "notifications" + ? { id: it.id, type: it.type, account: it.account?.acct, status_id: it.status?.id, created_at: it.created_at } + : { id: it.id, acct: it.account?.acct, url: it.url, content_excerpt: (it.content || "").replace(/<[^>]+>/g, "").slice(0, 240), created_at: it.created_at, visibility: it.visibility, reblogs: it.reblogs_count, favs: it.favourites_count }, + ); + return { content: [{ type: "text", text: JSON.stringify({ count: summary.length, items: summary }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- gts_search --- + server.tool( + "gts_search", + "Search accounts / hashtags / statuses across the fediverse. Remote queries resolve via WebFinger. Rate-limited: 60/hour.", + { + query: z.string().min(1).max(500).describe("Search string. Prefix with @ for accounts, # for tags, or a full URL to resolve a remote status."), + type: z.enum(["accounts", "hashtags", "statuses"]).optional().describe("Narrow to one result kind."), + limit: z.number().int().min(1).max(40).optional().describe("Max results per category."), + resolve: z.boolean().optional().describe("If true, hit WebFinger to resolve remote handles."), + }, + limiter("gts_search", async ({ query, type, limit, resolve }) => { + try { + const out = await gtsFetch("/api/v2/search", { + query: { q: query, type, limit: limit ?? 10, resolve: resolve ? "true" : undefined }, + }); + return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- gts_follow / gts_unfollow --- + server.tool( + "gts_follow", + "Follow an account by handle (@user@domain) or local account ID. Rate-limited: 30/hour.", + { handle: z.string().min(1).max(320).describe("Handle (@user@example.com) or account ID.") }, + limiter("gts_follow", async ({ handle }) => { + try { + let accountId = handle; + if (handle.startsWith("@") || handle.includes("@")) { + const search = await gtsFetch("/api/v2/search", { query: { q: handle.replace(/^@/, ""), type: "accounts", resolve: "true", limit: 1 } }); + const match = (search.accounts || [])[0]; + if (!match) return { content: [{ type: "text", text: `No account found for ${handle}` }] }; + accountId = match.id; + } + const rel = await gtsFetch(`/api/v1/accounts/${encodeURIComponent(accountId)}/follow`, { method: "POST" }); + return { content: [{ type: "text", text: JSON.stringify({ following: rel.following, requested: rel.requested, showing_reblogs: rel.showing_reblogs }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + server.tool( + "gts_unfollow", + "Unfollow an account.", + { handle: z.string().min(1).max(320) }, + limiter("gts_unfollow", async ({ handle }) => { + try { + let accountId = handle; + if (handle.startsWith("@") || handle.includes("@")) { + const search = await gtsFetch("/api/v2/search", { query: { q: handle.replace(/^@/, ""), type: "accounts", resolve: "true", limit: 1 } }); + const match = (search.accounts || [])[0]; + if (!match) return { content: [{ type: "text", text: `No account found for ${handle}` }] }; + accountId = match.id; + } + const rel = await gtsFetch(`/api/v1/accounts/${encodeURIComponent(accountId)}/unfollow`, { method: "POST" }); + return { content: [{ type: "text", text: JSON.stringify({ following: rel.following }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- User-level moderation (inline, rate-limited) --- + server.tool( + "gts_block_user", + "Block an account system-wide (the authenticated user no longer sees their posts and vice versa). Rate-limited: 5/hour.", + { + handle: z.string().min(1).max(320), + confirm: z.literal("yes").describe('Must be "yes" — advisory only; rate limiter is the real gate for user-level blocks.'), + }, + limiter("gts_block_user", async ({ handle }) => { + try { + const search = await gtsFetch("/api/v2/search", { query: { q: handle.replace(/^@/, ""), type: "accounts", resolve: "true", limit: 1 } }); + const match = (search.accounts || [])[0]; + if (!match) return { content: [{ type: "text", text: `No account found for ${handle}` }] }; + const rel = await gtsFetch(`/api/v1/accounts/${match.id}/block`, { method: "POST" }); + return { content: [{ type: "text", text: JSON.stringify({ blocking: rel.blocking }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + server.tool( + "gts_mute_user", + "Mute an account (hide posts but still federate). Rate-limited: 5/hour.", + { handle: z.string().min(1).max(320), confirm: z.literal("yes") }, + limiter("gts_mute_user", async ({ handle }) => { + try { + const search = await gtsFetch("/api/v2/search", { query: { q: handle.replace(/^@/, ""), type: "accounts", resolve: "true", limit: 1 } }); + const match = (search.accounts || [])[0]; + if (!match) return { content: [{ type: "text", text: `No account found for ${handle}` }] }; + const rel = await gtsFetch(`/api/v1/accounts/${match.id}/mute`, { method: "POST" }); + return { content: [{ type: "text", text: JSON.stringify({ muting: rel.muting }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- Instance-level moderation (queued: destructive, requires Nest click) --- + server.tool( + "gts_block_domain", + "Block an entire remote domain (no federation, no media fetch). QUEUED — requires operator confirmation in the Nest panel before firing.", + { + domain: z.string().min(1).max(253), + reason: z.string().max(500).optional(), + confirm: z.literal("yes"), + }, + async ({ domain, reason }) => { + const queued = await queueModerationAction("gotosocial", "block_domain", { domain, reason: reason || "" }); + return { content: [{ type: "text", text: JSON.stringify(queued, null, 2) }] }; + }, + ); + + server.tool( + "gts_defederate", + "Defederate from a remote domain (stop all ActivityPub interaction). Stronger than block_domain — existing follow relationships are severed. QUEUED — requires operator confirmation.", + { + domain: z.string().min(1).max(253), + reason: z.string().max(500).optional(), + confirm: z.literal("yes"), + }, + async ({ domain, reason }) => { + const queued = await queueModerationAction("gotosocial", "defederate", { domain, reason: reason || "" }); + return { content: [{ type: "text", text: JSON.stringify(queued, null, 2) }] }; + }, + ); + + server.tool( + "gts_review_reports", + "List pending moderation reports (local + federated). Read-only.", + { limit: z.number().int().min(1).max(100).optional() }, + async ({ limit }) => { + try { + const reports = await gtsFetch("/api/v1/admin/reports", { query: { limit: limit ?? 20, resolved: "false" } }); + const summary = (Array.isArray(reports) ? reports : []).map((r) => ({ + id: r.id, + account: r.account?.acct, + target_account: r.target_account?.acct, + reason: r.category || r.comment, + created_at: r.created_at, + })); + return { content: [{ type: "text", text: JSON.stringify({ count: summary.length, reports: summary }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + server.tool( + "gts_report_remote", + "Send a moderation report to a remote server about one of their accounts.", + { + handle: z.string().min(1).max(320), + reason: z.string().min(1).max(1000), + forward: z.boolean().optional().describe("Forward the report to the remote's moderators."), + }, + limiter("gts_report_remote", async ({ handle, reason, forward }) => { + try { + const search = await gtsFetch("/api/v2/search", { query: { q: handle.replace(/^@/, ""), type: "accounts", resolve: "true", limit: 1 } }); + const match = (search.accounts || [])[0]; + if (!match) return { content: [{ type: "text", text: `No account found for ${handle}` }] }; + const body = { account_id: match.id, comment: reason, forward: forward !== false }; + const out = await gtsFetch("/api/v1/reports", { method: "POST", body }); + return { content: [{ type: "text", text: JSON.stringify({ report_id: out.id, forwarded: body.forward }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + server.tool( + "gts_import_blocklist", + "Import a domain blocklist (IFTAS / The Bad Space / custom URL, one domain per line). QUEUED — requires operator confirmation before any domains are blocked. Rate-limited: 2/hour.", + { + source: z.string().min(1).max(500).describe("URL or 'iftas' / 'bad-space' for canonical sources."), + confirm: z.literal("yes"), + }, + limiter("gts_import_blocklist", async ({ source }) => { + const canonical = { + iftas: "https://connect.iftas.org/library/iftas-documentation/iftas-do-not-interact-list/", + "bad-space": "https://badspace.org/domain-block.csv", + }; + const url = canonical[source] || source; + const queued = await queueModerationAction("gotosocial", "import_blocklist", { source: url }); + return { content: [{ type: "text", text: JSON.stringify(queued, null, 2) }] }; + }), + ); + + // --- Disk / media management --- + server.tool( + "gts_media_prune", + "Manually trigger pruning of remote media older than N days. The scheduled cron (scripts/media-prune.sh) runs daily; this lets operators force an aggressive prune.", + { older_than_days: z.number().int().min(1).max(365).optional().describe("Default 14 (or 7 on Pi-class hosts).") }, + async ({ older_than_days }) => { + try { + if (!GTS_ACCESS_TOKEN) { + return { content: [{ type: "text", text: "Error: GTS_ACCESS_TOKEN required to invoke admin media prune." }] }; + } + const days = older_than_days ?? Number(process.env.GTS_MEDIA_RETENTION_DAYS || 14); + const out = await gtsFetch("/api/v1/admin/media_cleanup", { + method: "POST", + body: { remote_cache_days: days }, + }); + return { content: [{ type: "text", text: JSON.stringify({ pruned_days: days, response: out }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + return server; +} diff --git a/bundles/gotosocial/skills/gotosocial.md b/bundles/gotosocial/skills/gotosocial.md new file mode 100644 index 0000000..60620ed --- /dev/null +++ b/bundles/gotosocial/skills/gotosocial.md @@ -0,0 +1,142 @@ +--- +name: gotosocial +description: GoToSocial — lightweight ActivityPub microblog. Post, follow, search, moderate across the fediverse. +triggers: + - "gotosocial" + - "gts" + - "fediverse" + - "activitypub" + - "toot" + - "post to mastodon" + - "federated social" + - "follow @" +tools: + - gts_status + - gts_post + - gts_feed + - gts_search + - gts_follow + - gts_unfollow + - gts_block_user + - gts_mute_user + - gts_block_domain + - gts_defederate + - gts_review_reports + - gts_report_remote + - gts_import_blocklist + - gts_media_prune +--- + +# GoToSocial — fediverse microblog + +GoToSocial is a lightweight, Pi-friendly ActivityPub server. It speaks the +Mastodon-compatible API, so every fediverse client (Tusky, Elk, Ivory, +Mastodon web) works against it out of the box. Crow's Nest adds AI-facing +tools to post, browse, follow, and moderate. + +## Prerequisites (once, before first install) + +1. **Caddy must be installed** — GoToSocial declares `requires.bundles: ["caddy"]` so the dependency gate will refuse install otherwise. +2. **Subdomain with an A/AAAA record pointing at this host.** ActivityPub actors are URL-keyed; `example.com/gotosocial` does not work. Use `gts.example.com` or similar. +3. **Ports 80/443 reachable** — Caddy's ACME HTTP-01 challenge needs :80 inbound. +4. **Sufficient headroom** — the hardware gate refuses when effective RAM < 512 MB; 1 GB recommended. On a Pi set `GTS_MEDIA_RETENTION_DAYS=7`. + +## After install — expose via Caddy + +The bundle does not publish a host port. Caddy reaches GoToSocial over the shared `crow-federation` docker network by service name. + +``` +caddy_add_federation_site { + "domain": "gts.example.com", + "upstream": "gotosocial:8080", + "profile": "activitypub", + "wellknown": { + "nodeinfo": { "href": "https://gts.example.com/nodeinfo/2.0" } + } +} +``` + +Caddy validates the block via `/load` before writing the Caddyfile, then issues a real Let's Encrypt cert on first request (usually within 60 seconds). Confirm with `caddy_cert_health { "domain": "gts.example.com" }` — status should be `ok`. + +If the account domain differs from the host domain (e.g. `@alice@example.com` where the server is at `gts.example.com`), also delegate WebFinger on the apex: + +``` +caddy_set_wellknown { + "domain": "example.com", + "kind": "webfinger", + "body_json": "{\"links\":[{\"rel\":\"lrdd\",\"template\":\"https://gts.example.com/.well-known/webfinger?resource={uri}\"}]}" +} +``` + +## Generating an access token + +The first admin user creates themselves via the web UI at `https:///`. To generate an API token for the Crow MCP server: + +```bash +docker exec crow-gotosocial ./gotosocial admin account create-token \ + --username +``` + +Paste the returned token into `.env` as `GTS_ACCESS_TOKEN`, then restart the MCP server. + +Without a token, the Crow tools are limited to the public instance read surface (public timelines, instance info, unresolved search). + +## Common workflows + +### Post a status + +``` +gts_post { "status": "Hello from Crow!", "visibility": "public" } +``` + +Visibility values mirror Mastodon's: `public` (federated + public timelines), `unlisted` (federated, not on timelines), `private` (followers only), `direct` (DM-like). + +Rate-limited to 10 posts per hour per conversation — the limiter is SQLite-persisted, so restarting the bundle does not reset the window. + +### Follow a remote account + +``` +gts_follow { "handle": "@Gargron@mastodon.social" } +``` + +The tool resolves the handle via WebFinger, then calls the follow API. Rate-limited to 30/hour. + +### Check what's new + +``` +gts_feed { "source": "notifications", "limit": 10 } +gts_feed { "source": "home", "limit": 20 } +``` + +### Inbox hygiene + +``` +gts_review_reports { "limit": 20 } +gts_block_user { "handle": "@spammer@badinstance.com", "confirm": "yes" } +``` + +`block_user` / `mute_user` fire inline (single-account scope, rate-limited). `block_domain` / `defederate` / `import_blocklist` do NOT — they queue a pending action and raise a Crow notification. The operator confirms from the Nest panel before the action actually runs. The `confirm: "yes"` arg is advisory for the AI only; the authorization is the operator's click. + +### Media cache management + +Remote media caches grow substantially under active federation. Automatic pruning runs daily via `scripts/media-prune.sh`; the retention window (days) comes from `GTS_MEDIA_RETENTION_DAYS` (default 14, or 7 on Pi). + +Force a prune: + +``` +gts_media_prune { "older_than_days": 7 } +``` + +## Federation etiquette — not optional + +- Never spam public timelines. The rate limiter is a floor, not a license. +- Moderation is your problem. A hosted instance that doesn't moderate gets defederated by major hubs within days; rehab is not easy. +- Consider importing a starter blocklist at install (IFTAS or The Bad Space). This is opt-in via the install consent modal. +- Published content is effectively permanent — delete activities propagate asynchronously and inconsistently. Treat every post as public archive material. + +## Troubleshooting + +- **"Cannot reach GoToSocial at http://gotosocial:8080"** — the MCP server runs on the host but the container is named on the `crow-federation` docker network. Fix: either run the MCP server inside that network, or set `GTS_URL=http://127.0.0.1:` and add a `profiles: ["debug"]` host-port publish to the compose (then `docker compose --profile debug up -d`). +- **"401 — auth failed"** — `GTS_ACCESS_TOKEN` is unset or revoked. Regenerate via the admin CLI command above. +- **"Let's Encrypt rate-limited"** — hitting LE's 5-duplicate-certs-per-week limit. Wait for the reset or use a different domain. +- **Federation not working (local posts fine, remotes don't see them)** — check `caddy_cert_health`; if staging cert or expiry problems, remote servers reject TLS. diff --git a/registry/add-ons.json b/registry/add-ons.json index a34e128..c18547e 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -3003,6 +3003,75 @@ }, "notes": "Privacy-first budgeting. Data stays local. Password-based auth. 7 MCP tools for accounts, transactions, budgets, and reports." }, + { + "id": "gotosocial", + "name": "GoToSocial", + "description": "Lightweight ActivityPub microblog server (fediverse). Pi-friendly single-binary alternative to Mastodon — joins the public fediverse with a sub-500 MB footprint.", + "type": "bundle", + "version": "1.0.0", + "author": "Crow", + "category": "federated-social", + "tags": [ + "activitypub", + "fediverse", + "microblog", + "federated", + "mastodon-compatible" + ], + "icon": "globe", + "docker": { + "composefile": "docker-compose.yml" + }, + "server": { + "command": "node", + "args": ["server/index.js"], + "envKeys": ["GTS_URL", "GTS_ACCESS_TOKEN"] + }, + "panel": "panel/gotosocial.js", + "panelRoutes": "panel/routes.js", + "skills": ["skills/gotosocial.md"], + "consent_required": true, + "requires": { + "env": ["GTS_HOST", "GTS_ACCOUNT_DOMAIN"], + "bundles": ["caddy"], + "min_ram_mb": 512, + "recommended_ram_mb": 1024, + "min_disk_mb": 2000, + "recommended_disk_mb": 20000 + }, + "env_vars": [ + { + "name": "GTS_HOST", + "description": "Public domain for this GoToSocial instance (must be a subdomain; subpath mounts break ActivityPub).", + "required": true + }, + { + "name": "GTS_ACCOUNT_DOMAIN", + "description": "Account handle domain (usually same as GTS_HOST; otherwise needs apex WebFinger delegation).", + "required": true + }, + { + "name": "GTS_ACCESS_TOKEN", + "description": "Admin API token (generate via docker exec ... admin account create-token).", + "required": false, + "secret": true + }, + { + "name": "GTS_MEDIA_RETENTION_DAYS", + "description": "Days to retain remote media cache (default 14; Pi-class should lower to 7).", + "default": "14", + "required": false + }, + { + "name": "GTS_IMPORT_BLOCKLIST", + "description": "Optional: URL to an IFTAS / Bad Space / custom domain blocklist (imported once post-install).", + "required": false + } + ], + "ports": [], + "webUI": null, + "notes": "No host port publish. Expose via Caddy after install: caddy_add_federation_site { domain, upstream: 'gotosocial:8080', profile: 'activitypub' }." + }, { "id": "developer-kit", "name": "Developer Kit", diff --git a/servers/gateway/dashboard/nav-registry.js b/servers/gateway/dashboard/nav-registry.js index 15bc77e..ccea641 100644 --- a/servers/gateway/dashboard/nav-registry.js +++ b/servers/gateway/dashboard/nav-registry.js @@ -43,6 +43,9 @@ const CATEGORY_TO_GROUP = { automation: "tools", education: "content", system: "system", + "federated-social": "core", + "federated-media": "media", + "federated-comms": "core", }; /** diff --git a/servers/gateway/dashboard/panels/extensions.js b/servers/gateway/dashboard/panels/extensions.js index d3665ed..399c6de 100644 --- a/servers/gateway/dashboard/panels/extensions.js +++ b/servers/gateway/dashboard/panels/extensions.js @@ -68,6 +68,9 @@ const CATEGORY_COLORS = { infrastructure: { bg: "rgba(148,163,184,0.12)", color: "#94a3b8" }, automation: { bg: "rgba(45,212,191,0.12)", color: "#2dd4bf" }, education: { bg: "rgba(132,204,22,0.12)", color: "#84cc16" }, + "federated-social": { bg: "rgba(217,70,239,0.12)", color: "#d946ef" }, + "federated-media": { bg: "rgba(236,72,153,0.12)", color: "#f472b6" }, + "federated-comms": { bg: "rgba(167,139,250,0.12)", color: "#a78bfa" }, other: { bg: "rgba(161,161,170,0.12)", color: "#a1a1aa" }, }; @@ -85,6 +88,9 @@ const CATEGORY_LABELS = { infrastructure: "extensions.categoryInfrastructure", automation: "extensions.categoryAutomation", education: "extensions.categoryEducation", + "federated-social": "extensions.categoryFederatedSocial", + "federated-media": "extensions.categoryFederatedMedia", + "federated-comms": "extensions.categoryFederatedComms", other: "extensions.categoryOther", }; diff --git a/servers/gateway/dashboard/shared/i18n.js b/servers/gateway/dashboard/shared/i18n.js index f1387bb..f5c9c1b 100644 --- a/servers/gateway/dashboard/shared/i18n.js +++ b/servers/gateway/dashboard/shared/i18n.js @@ -413,6 +413,9 @@ const translations = { "extensions.categoryInfrastructure": { en: "Infrastructure", es: "Infraestructura" }, "extensions.categoryAutomation": { en: "Automation", es: "Automatizaci\u00f3n" }, "extensions.categoryEducation": { en: "Education", es: "Educaci\u00f3n" }, + "extensions.categoryFederatedSocial": { en: "Federated (Social)", es: "Federado (Social)" }, + "extensions.categoryFederatedMedia": { en: "Federated (Media)", es: "Federado (Medios)" }, + "extensions.categoryFederatedComms": { en: "Federated (Chat)", es: "Federado (Chat)" }, "extensions.categoryOther": { en: "Other", es: "Otros" }, // ─── Skills Panel ─── diff --git a/skills/superpowers.md b/skills/superpowers.md index d81a779..f4cabda 100644 --- a/skills/superpowers.md +++ b/skills/superpowers.md @@ -79,6 +79,7 @@ This is the master routing skill. Consult this **before every task** to determin | "test round", "run tests on", "test on claude.ai", "iterative testing" | "ronda de pruebas", "probar en claude.ai", "pruebas iterativas" | iterative-testing | crow-memory | | "organize notes", "brain dump", "sort these ideas", "help me plan from these" | "organizar notas", "ideas sueltas", "ordenar estas ideas", "aquí están mis notas" | ideation | crow-memory, crow-projects | | "schedule", "remind me", "every day at", "recurring" | "programar", "recuérdame", "cada día a las" | scheduling | crow-memory | +| "toot", "post to fediverse", "follow @user@...", "mastodon", "gotosocial", "activitypub" | "publicar en fediverso", "tootear", "seguir @usuario@...", "mastodon", "gotosocial" | gotosocial | crow-gotosocial | | "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 |