mirror of
https://github.com/prdlk/vortex.git
synced 2026-08-02 17:31:41 +00:00
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:
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user