Commit b64ace7a0c16

Vincent Demeester <vincent@sbr.pm>
2026-01-28 12:34:14
feat(slack-archive): add Slack public channel archiver
Add a tool to archive public Slack channels with static HTML export. Uses slackdump for incremental backup and slack-export-viewer for HTML generation via uvx. Features: - Archives only public channels (filters out DMs, private channels) - Incremental backups using slackdump's resume feature - Generates static HTML viewer for browsing archived messages - Fixes empty link bug in slack-export-viewer output Includes systemd service for aomi with daily timer. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e125c78
Changed files (4)
pkgs/default.nix
@@ -37,6 +37,7 @@ in
   jellyfin-manage-playlist = pkgs.callPackage ../tools/jellyfin-manage-playlist { };
   music-playlist-dl = pkgs.callPackage ../tools/music-playlist-dl { };
   nix-flake-update = pkgs.callPackage ../tools/nix-flake-update { };
+  slack-archive = pkgs.callPackage ../tools/slack-archive { };
   nixpkgs-consolidate = pkgs.callPackage ../tools/nixpkgs-consolidate { };
   gcal-to-org = pkgs.callPackage ../tools/gcal-to-org { };
   review-tool = pkgs.callPackage ../tools/review-tool { };
systems/aomi/extra.nix
@@ -276,8 +276,55 @@
   systemd.tmpfiles.rules = [
     "d /var/lib/ollama-exporter 0755 root root -"
     "d /var/lib/git-builds 0755 builder users -"
+    "d /var/lib/slack-archive 0750 vincent users -"
   ];
 
+  # Slack Archive - daily backup of public Slack channels
+  systemd.services.slack-archive = {
+    description = "Slack Public Channel Archiver";
+    after = [ "network-online.target" ];
+    wants = [ "network-online.target" ];
+
+    serviceConfig = {
+      Type = "oneshot";
+      User = "vincent";
+      Group = "users";
+      ExecStart = "${pkgs.slack-archive}/bin/slack-archive archive";
+      Environment = [
+        "SLACK_ARCHIVE_DIR=/var/lib/slack-archive"
+        "HOME=/home/vincent"
+        "XDG_CACHE_HOME=/home/vincent/.cache"
+      ];
+
+      # Security hardening
+      PrivateTmp = true;
+      ProtectSystem = "strict";
+      ProtectHome = "read-only";
+      ReadWritePaths = [
+        "/var/lib/slack-archive"
+        "/home/vincent/.cache/slackdump"
+        "/home/vincent/.local/cache/slackdump"
+      ];
+      NoNewPrivileges = true;
+
+      # Logging
+      StandardOutput = "journal";
+      StandardError = "journal";
+      SyslogIdentifier = "slack-archive";
+    };
+  };
+
+  systemd.timers.slack-archive = {
+    description = "Daily Slack Archive Timer";
+    wantedBy = [ "timers.target" ];
+
+    timerConfig = {
+      OnCalendar = "daily";
+      RandomizedDelaySec = 3600; # 0-1 hour random delay
+      Persistent = true;
+    };
+  };
+
   systemd.services.ollama-exporter = {
     description = "Ollama Prometheus Exporter";
     after = [
tools/slack-archive/default.nix
@@ -0,0 +1,48 @@
+{
+  lib,
+  stdenv,
+  makeWrapper,
+  slackdump,
+  jq,
+  findutils,
+  gnused,
+  uv,
+  python3,
+}:
+
+stdenv.mkDerivation {
+  pname = "slack-archive";
+  version = "0.1.0";
+
+  src = ./.;
+
+  nativeBuildInputs = [ makeWrapper ];
+
+  installPhase = ''
+    runHook preInstall
+
+    mkdir -p $out/bin
+    cp slack-archive.sh $out/bin/slack-archive
+    chmod +x $out/bin/slack-archive
+
+    wrapProgram $out/bin/slack-archive \
+      --prefix PATH : ${
+        lib.makeBinPath [
+          slackdump
+          jq
+          findutils
+          gnused
+          uv
+          python3
+        ]
+      }
+
+    runHook postInstall
+  '';
+
+  meta = with lib; {
+    description = "Slack public channel archiver with static HTML export";
+    license = licenses.mit;
+    platforms = platforms.linux;
+  };
+}
tools/slack-archive/slack-archive.sh
@@ -0,0 +1,155 @@
+#!/usr/bin/env bash
+# Slack Public Channels Archive Script
+# Archives public channels incrementally and generates static HTML viewer
+set -euo pipefail
+
+# --- Configuration ---
+DATA_DIR="${SLACK_ARCHIVE_DIR:-/var/lib/slack-archive}"
+ARCHIVE_DIR="$DATA_DIR/archive"
+EXPORT_DIR="$DATA_DIR/exports"
+HTML_DIR="$DATA_DIR/html"
+CHANNELS_FILE="$DATA_DIR/public-channels.txt"
+CHANNELS_JSON="$DATA_DIR/channels.json"
+
+# How often to refresh channel list (in days)
+CHANNEL_REFRESH_DAYS="${CHANNEL_REFRESH_DAYS:-7}"
+
+# --- Functions ---
+
+log() {
+    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
+}
+
+die() {
+    log "ERROR: $*" >&2
+    exit 1
+}
+
+check_auth() {
+    # Check for cached credentials
+    local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/slackdump"
+    if [[ -f "$cache_dir/provider.bin" ]]; then
+        log "Using cached credentials from $cache_dir"
+        return 0
+    fi
+
+    die "No cached credentials found. Run 'slackdump login' interactively first."
+}
+
+setup_dirs() {
+    mkdir -p "$DATA_DIR" "$EXPORT_DIR" "$HTML_DIR"
+}
+
+refresh_channel_list() {
+    local should_refresh=false
+
+    if [[ ! -f "$CHANNELS_FILE" ]]; then
+        log "Channel list not found, fetching..."
+        should_refresh=true
+    elif [[ -n "$(find "$CHANNELS_FILE" -mtime +"$CHANNEL_REFRESH_DAYS" 2>/dev/null)" ]]; then
+        log "Channel list older than $CHANNEL_REFRESH_DAYS days, refreshing..."
+        should_refresh=true
+    fi
+
+    if [[ "$should_refresh" == "true" ]] || [[ "${FORCE_REFRESH:-}" == "true" ]]; then
+        log "Fetching channel list from Slack..."
+        slackdump list channels -format JSON -no-json > "$CHANNELS_JSON"
+
+        # Filter to public channels only (exclude private, DMs, group DMs)
+        jq -r '.[] | select(.is_private == false and .is_im == false and .is_mpim == false) | .id' \
+            "$CHANNELS_JSON" > "$CHANNELS_FILE"
+
+        local count
+        count=$(wc -l < "$CHANNELS_FILE")
+        log "Found $count public channels"
+    else
+        log "Using cached channel list ($(wc -l < "$CHANNELS_FILE") channels)"
+    fi
+}
+
+run_archive() {
+    if [[ -d "$ARCHIVE_DIR" ]]; then
+        log "Resuming archive from previous state..."
+        slackdump resume "$ARCHIVE_DIR"
+    else
+        log "Starting fresh archive..."
+        slackdump archive -o "$ARCHIVE_DIR" @"$CHANNELS_FILE"
+    fi
+}
+
+convert_to_export() {
+    EXPORT_FILE="$EXPORT_DIR/slack-export-$(date +%Y-%m-%d).zip"
+    log "Converting archive to export format: $EXPORT_FILE"
+    slackdump convert -f export -storage standard -o "$EXPORT_FILE" "$ARCHIVE_DIR"
+}
+
+generate_html() {
+    local export_file="$1"
+    log "Generating HTML viewer..."
+    uvx slack-export-viewer -z "$export_file" --html-only -o "$HTML_DIR"
+
+    # Fix empty anchor tags (slack-export-viewer bug)
+    # Converts <a href='URL'></a> to <a href='URL'>URL</a>
+    log "Fixing empty links..."
+    find "$HTML_DIR" -name "*.html" -exec sed -i -E "s|<a href='([^']+)'></a>|<a href='\\1'>\\1</a>|g" {} \;
+
+    log "HTML generated at: $HTML_DIR"
+}
+
+serve_html() {
+    local port="${1:-8080}"
+    log "Starting server at http://localhost:$port"
+    python3 -m http.server -d "$HTML_DIR" "$port"
+}
+
+# --- Main ---
+
+main() {
+    local cmd="${1:-archive}"
+
+    case "$cmd" in
+        archive)
+            check_auth
+            setup_dirs
+            refresh_channel_list
+            run_archive
+            convert_to_export
+            generate_html "$EXPORT_FILE"
+            log "Done! View at: $HTML_DIR/index.html"
+            ;;
+        channels)
+            check_auth
+            setup_dirs
+            FORCE_REFRESH=true refresh_channel_list
+            ;;
+        html)
+            local latest_export
+            latest_export=$(find "$EXPORT_DIR" -maxdepth 1 -name "*.zip" -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)
+            if [[ -z "$latest_export" ]]; then
+                die "No export found. Run '$0 archive' first."
+            fi
+            generate_html "$latest_export"
+            ;;
+        serve)
+            serve_html "${2:-8080}"
+            ;;
+        help|--help|-h)
+            echo "Usage: $0 [command]"
+            echo ""
+            echo "Commands:"
+            echo "  archive   Fetch channels, archive, convert, generate HTML (default)"
+            echo "  channels  Refresh channel list only"
+            echo "  html      Regenerate HTML from latest export"
+            echo "  serve     Start local HTTP server (port 8080 or specify)"
+            echo "  help      Show this help"
+            echo ""
+            echo "Environment:"
+            echo "  SLACK_ARCHIVE_DIR   Data directory (default: /var/lib/slack-archive)"
+            ;;
+        *)
+            die "Unknown command: $cmd. Run '$0 help' for usage."
+            ;;
+    esac
+}
+
+main "$@"