feat: unified messaging TUI MVP — Synapse + 7 mautrix bridges + Textual TUI + MCP draft staging

One-command local stack: cp .env.example .env && make up. Idempotent
bootstrap renders Synapse + bridge configs from pristine upstream examples,
provisions databases, registrations, double puppeting, and the admin user.
Textual TUI with bridge manager, in-terminal QR login, and drafts panel.
MCP server exposes read tools and a human-gated draft send workflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Prad Nukala
2026-07-08 12:13:13 -04:00
co-authored by Claude Fable 5
commit d57086cd00
29 changed files with 5602 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
FROM alpine:3.20
RUN apk add --no-cache bash curl openssl yq gettext postgresql16-client python3
COPY bootstrap.sh provision.sh /
RUN chmod +x /bootstrap.sh /provision.sh
CMD ["/bootstrap.sh"]
+181
View File
@@ -0,0 +1,181 @@
#!/bin/bash
# Idempotent one-shot provisioning: renders Synapse + bridge configs, creates
# bridge databases, generates registrations. Safe to re-run on every `up`.
set -euo pipefail
DATA=/data
SECRETS=$DATA/secrets
DOMAIN=${MATRIX_SERVER_NAME:-localhost}
ADMIN_MXID="@${MATRIX_USER:-admin}:${DOMAIN}"
ALL_BRIDGES="gmessages telegram whatsapp twitter linkedin discord meta"
log() { echo "[bootstrap] $*"; }
mkdir -p "$SECRETS" "$DATA/synapse/appservices" "$DATA/shared/drafts" "$DATA/shared/tui-store" "$DATA/shared/mcp-store"
for b in $ALL_BRIDGES; do mkdir -p "$DATA/bridges/$b"; done # subpath mounts need these even for disabled bridges
chmod -R a+rwX "$DATA/shared"
# ── secrets ─────────────────────────────────────────────────────────
# secret_of NAME ENV_VALUE: if env value is "auto"/"auto-or-changeme"/empty,
# generate once and persist; else use env value verbatim.
secret_of() {
local name=$1 val=${2:-auto} f=$SECRETS/$1
case "$val" in auto|auto-or-changeme|"")
[ -f "$f" ] || openssl rand -hex 32 > "$f"
cat "$f" ;;
*) echo "$val" ;;
esac
}
REG_SECRET=$(secret_of registration_shared_secret "${REGISTRATION_SHARED_SECRET:-auto}")
DP_SECRET=$(secret_of doublepuppet_shared_secret "${DOUBLEPUPPET_SHARED_SECRET:-auto}")
MACAROON=$(secret_of macaroon_secret_key auto)
FORM=$(secret_of form_secret auto)
# per-bridge appservice tokens, generated once
bridge_token() { # bridge kind(as|hs)
local f=$SECRETS/${1}_${2}_token
[ -f "$f" ] || openssl rand -hex 32 > "$f"
cat "$f"
}
# ── synapse ─────────────────────────────────────────────────────────
if [ ! -f "$DATA/synapse/signing.key" ]; then
echo "ed25519 a_$(openssl rand -hex 2) $(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_')" > "$DATA/synapse/signing.key"
log "generated synapse signing key"
fi
cat > "$DATA/synapse/log.config" <<'EOF'
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
handlers:
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
level: INFO
root:
level: INFO
handlers: [console]
disable_existing_loggers: false
EOF
IFS=',' read -ra ENABLED <<< "${BRIDGES_ENABLED:-}"
enabled() { local b; for b in "${ENABLED[@]}"; do [ "$b" = "$1" ] && return 0; done; return 1; }
# telegram needs API creds; drop it from the effective set if missing
telegram_ok=true
if enabled telegram && { [ -z "${TELEGRAM_API_ID:-}" ] || [ -z "${TELEGRAM_API_HASH:-}" ]; }; then
telegram_ok=false
log "WARNING: telegram enabled but TELEGRAM_API_ID/TELEGRAM_API_HASH empty - skipping telegram bridge"
fi
export MATRIX_SERVER_NAME="$DOMAIN" POSTGRES_PASSWORD REGISTRATION_SHARED_SECRET="$REG_SECRET" \
MACAROON_SECRET_KEY="$MACAROON" FORM_SECRET="$FORM"
envsubst < /templates/synapse/homeserver.template.yaml > "$DATA/synapse/homeserver.yaml.new"
# ── double puppet appservice ────────────────────────────────────────
DP_HS_TOKEN=$(bridge_token doublepuppet hs)
cat > "$DATA/synapse/appservices/doublepuppet.yaml" <<EOF
id: doublepuppet
url:
as_token: "$DP_SECRET"
hs_token: "$DP_HS_TOKEN"
sender_localpart: doublepuppet-sender
rate_limited: false
namespaces:
users:
- regex: '@.*:$(echo "$DOMAIN" | sed 's/\./\\./g')'
exclusive: false
EOF
echo "app_service_config_files:" >> "$DATA/synapse/homeserver.yaml.new"
echo " - /data/appservices/doublepuppet.yaml" >> "$DATA/synapse/homeserver.yaml.new"
# ── bridges ─────────────────────────────────────────────────────────
pg() { PGPASSWORD=$POSTGRES_PASSWORD psql -h postgres -U vortex -d postgres -qtAc "$1"; }
for b in $ALL_BRIDGES; do
enabled "$b" || continue
[ "$b" = telegram ] && [ "$telegram_ok" = false ] && continue
db="mautrix_$b"
if [ "$(pg "SELECT 1 FROM pg_database WHERE datname='$db'")" != 1 ]; then
pg "CREATE DATABASE $db OWNER vortex" >/dev/null
log "created database $db"
fi
mkdir -p "$DATA/bridges/$b"
cfg=$DATA/bridges/$b/config.yaml
AS_TOKEN=$(bridge_token "$b" as)
HS_TOKEN=$(bridge_token "$b" hs)
port=$(yq '.appservice.port' "/templates/bridges/$b.yaml")
if [ ! -f "$cfg" ]; then
cp "/templates/bridges/$b.yaml" "$cfg"
export B="$b" PORT="$port" AS_TOKEN HS_TOKEN DP_SECRET DOMAIN ADMIN_MXID \
DB_URI="postgres://vortex:${POSTGRES_PASSWORD}@postgres/${db}?sslmode=disable"
yq -i '
.homeserver.address = "http://synapse:8008" |
.homeserver.domain = strenv(DOMAIN) |
.appservice.address = "http://" + strenv(B) + ":" + (strenv(PORT) | tostring) |
.appservice.hostname = "0.0.0.0" |
.appservice.as_token = strenv(AS_TOKEN) |
.appservice.hs_token = strenv(HS_TOKEN)
' "$cfg"
if [ "$b" = discord ]; then # legacy config layout
yq -i '
.appservice.database.type = "postgres" |
.appservice.database.uri = strenv(DB_URI) |
.bridge.permissions = {"*": "relay", strenv(DOMAIN): "user", strenv(ADMIN_MXID): "admin"} |
.bridge.login_shared_secret_map = {strenv(DOMAIN): ("as_token:" + strenv(DP_SECRET))}
' "$cfg"
else # bridgev2 layout
yq -i '
.database.type = "postgres" |
.database.uri = strenv(DB_URI) |
.bridge.permissions = {"*": "relay", strenv(DOMAIN): "user", strenv(ADMIN_MXID): "admin"} |
.double_puppet.servers = {} |
.double_puppet.secrets = {strenv(DOMAIN): ("as_token:" + strenv(DP_SECRET))} |
.provisioning.shared_secret = "disable"
' "$cfg"
fi
if [ "$b" = telegram ]; then
yq -i '.network.api_id = (strenv(TELEGRAM_API_ID) | tonumber) | .network.api_hash = strenv(TELEGRAM_API_HASH)' "$cfg"
fi
if [ "$b" = meta ]; then
yq -i '.network.mode = "instagram"' "$cfg"
fi
log "rendered $b config"
fi
# registration: regenerated deterministically from persisted tokens.
# Written to the bridge dir too, else the mautrix docker-run.sh entrypoint
# regenerates it with fresh tokens and clobbers the ones in config.yaml.
esc_domain=$(echo "$DOMAIN" | sed 's/\./\\./g')
cat > "$DATA/bridges/$b/registration.yaml" <<EOF
id: $b
url: http://$b:$port
as_token: "$AS_TOKEN"
hs_token: "$HS_TOKEN"
sender_localpart: ${b}-as-sender
rate_limited: false
de.sorunome.msc2409.push_ephemeral: true
receive_ephemeral: true
namespaces:
users:
- regex: '@${b}bot:${esc_domain}'
exclusive: true
- regex: '@${b}_.*:${esc_domain}'
exclusive: true
EOF
cp "$DATA/bridges/$b/registration.yaml" "$DATA/synapse/appservices/$b.yaml"
echo " - /data/appservices/$b.yaml" >> "$DATA/synapse/homeserver.yaml.new"
log "bridge $b ready (port $port)"
done
mv "$DATA/synapse/homeserver.yaml.new" "$DATA/synapse/homeserver.yaml"
chown -R 991:991 "$DATA/synapse" # synapse image runs as uid 991
log "done"
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# One-shot post-synapse provisioning: create admin user (idempotent) and
# write /data/shared/credentials.json for the TUI and MCP server.
set -euo pipefail
DATA=/data
DOMAIN=${MATRIX_SERVER_NAME:-localhost}
USER=${MATRIX_USER:-admin}
PASS=${MATRIX_PASSWORD:?}
MXID="@${USER}:${DOMAIN}"
HS=http://synapse:8008
REG_SECRET=$(cat $DATA/secrets/registration_shared_secret 2>/dev/null || true)
log() { echo "[provision] $*"; }
# already provisioned and token still valid? then no-op
if [ -f "$DATA/shared/credentials.json" ]; then
TOK=$(yq -p json '.access_token' "$DATA/shared/credentials.json")
if curl -sf -H "Authorization: Bearer $TOK" "$HS/_matrix/client/v3/account/whoami" >/dev/null; then
log "credentials already valid, nothing to do"
exit 0
fi
fi
login() {
curl -sf -X POST "$HS/_matrix/client/v3/login" \
-d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"$USER\"},\"password\":$(python3 -c "import json,sys;print(json.dumps(sys.argv[1]))" "$PASS"),\"initial_device_display_name\":\"provision\"}"
}
RESP=$(login || true)
if [ -z "$RESP" ]; then
log "admin user missing, registering $MXID"
NONCE=$(curl -sf "$HS/_synapse/admin/v1/register" | yq -p json '.nonce')
MAC=$(printf '%s\0%s\0%s\0admin' "$NONCE" "$USER" "$PASS" | openssl dgst -sha1 -hmac "$REG_SECRET" | awk '{print $NF}')
RESP=$(python3 - "$NONCE" "$USER" "$PASS" "$MAC" <<'EOF' | curl -sf -X POST "$HS/_synapse/admin/v1/register" -d @-
import json, sys
n, u, p, m = sys.argv[1:5]
print(json.dumps({"nonce": n, "username": u, "password": p, "admin": True, "mac": m}))
EOF
)
[ -n "$RESP" ] || { log "registration failed"; exit 1; }
fi
TOKEN=$(echo "$RESP" | yq -p json '.access_token')
DEVICE=$(echo "$RESP" | yq -p json '.device_id')
python3 - "$HS" "$MXID" "$PASS" "$TOKEN" "$DEVICE" <<'EOF' > $DATA/shared/credentials.json.new
import json, sys
hs, mxid, pw, tok, dev = sys.argv[1:6]
print(json.dumps({"homeserver": hs, "user_id": mxid, "password": pw,
"access_token": tok, "device_id": dev}, indent=2))
EOF
mv $DATA/shared/credentials.json.new $DATA/shared/credentials.json
chmod a+rw $DATA/shared/credentials.json
log "credentials written for $MXID"