MeetCap is a self-hosted Discord meeting assistant for a small internal development team. The MVP target is a Rust Discord bot plus a separate transcription worker, with local transcription through whisper.cpp.
This repository is a public sample snapshot of MVP-1 for a YouTube demo by @ClickBober. It shows a self-hosted MeetCap Discord bot solution using Gitea and a private Docker registry as the DevOps platform.
Current repository state: the Rust workspace includes shared domain and worker contract types, a Serenity/Songbird-based Discord bot command foundation, SQLite/file-backed meeting session metadata, voice recording and audio normalization, a worker HTTP API, a gated whisper-cli execution boundary, and worker-generated summary posting back through the bot. Docker-backed real model validation remains the main active quality task.
.
|-- apps/
| |-- discord-bot/
| | |-- Cargo.toml
| | `-- src/
| | |-- lib.rs
| | `-- main.rs
| `-- transcription-worker/
| |-- Cargo.toml
| `-- src/
| |-- lib.rs
| `-- main.rs
|-- crates/
| |-- meetcap-domain/
| |-- meetcap-storage/
| `-- meetcap-worker-client/
|-- infra/
| |-- docker/
| | `-- ci/
| `-- docker-compose/
|-- docs/
| |-- api_protocol_v1.md
| |-- design.md
| |-- handoff.md
| |-- scope.md
| |-- tracker.md
| `-- methodology/
|-- Cargo.toml
|-- Cargo.lock
`-- README.md
| Package | Path | Purpose |
|---|---|---|
meetcap-discord-bot |
apps/discord-bot |
Discord Gateway, slash commands, voice coordination, recording orchestration, and future summary posting. |
meetcap-transcription-worker |
apps/transcription-worker |
Internal worker HTTP API, transcription job state, whisper-cli STT orchestration, and future summary generation. |
meetcap-domain |
crates/meetcap-domain |
Shared meeting/session/job/artifact types and state transition logic. |
meetcap-storage |
crates/meetcap-storage |
Shared /data and session artifact path helpers for SQLite/file metadata. |
meetcap-worker-client |
crates/meetcap-worker-client |
Bot-side DTOs for the internal worker API described in docs/api_protocol_v1.md. |
- Install Rust, preferably through
rustup. - Use a Rust toolchain with edition 2024 support. The workspace minimum is Rust
1.85. - Install native Opus build prerequisites for Songbird voice support:
- macOS/Homebrew:
brew install cmake pkg-config opus - Debian/Ubuntu:
apt-get install -y cmake pkg-config libopus-dev
- macOS/Homebrew:
- Verify the toolchain:
rustc --version cargo --version
- Check that the workspace compiles:
cargo check --workspace
No Discord credentials or whisper.cpp model files are required for normal unit tests. FFmpeg is needed for bot audio normalization tests and local recording conversion. Local app binaries run from source with cargo; Docker Compose is reserved for external dependencies and VPS deployment.
T-011 uses Pattern 2 from docs/design.md: the MeetCap worker owns the HTTP API
and shells out to whisper-cli. For normal tests, this backend stays disabled.
Enable it only when you have a local whisper.cpp build and model file.
On macOS Apple Silicon, including an M3 Max development machine, the default
local setup is a repo-local checkout under .local/meetcap/vendor/. Generated
binaries and downloaded models stay outside committed source because .local/
is ignored.
brew install cmake ffmpeg
mkdir -p .local/meetcap/vendor .local/meetcap/models
git clone https://github.com/ggml-org/whisper.cpp.git .local/meetcap/vendor/whisper.cpp
cd .local/meetcap/vendor/whisper.cpp
cmake -B build
cmake --build build -j --config Release
sh ./models/download-ggml-model.sh base
cd -If you already have another local whisper.cpp checkout, you can seed the
repo-local checkout without network access:
mkdir -p .local/meetcap/vendor .local/meetcap/models
git clone /path/to/existing/whisper.cpp .local/meetcap/vendor/whisper.cpp
cp /path/to/existing/whisper.cpp/models/ggml-base.bin .local/meetcap/models/ggml-base.binIf cmake --build fails on macOS with errors such as array/mutex/cstdio
not found, this Command Line Tools install is missing the default libc++ header
search path. Reconfigure with the SDK include path explicitly:
cmake -B build \
"-DCMAKE_OSX_SYSROOT=/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk" \
"-DCMAKE_CXX_FLAGS=-isystem /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1"
cmake --build build -j --config ReleaseUse base, not base.en, because MeetCap needs Ukrainian, Russian, and
English. The model download above creates:
.local/meetcap/vendor/whisper.cpp/models/ggml-base.bin
You can either point MeetCap directly at that file, or copy/symlink it into the shared local model directory:
cp .local/meetcap/vendor/whisper.cpp/models/ggml-base.bin .local/meetcap/models/ggml-base.binQuick upstream sanity check:
.local/meetcap/vendor/whisper.cpp/build/bin/whisper-cli \
-m .local/meetcap/models/ggml-base.bin \
-f .local/meetcap/vendor/whisper.cpp/samples/jfk.wav \
-l auto \
-t 8Large model
.local/meetcap/vendor/whisper.cpp/build/bin/whisper-cli \
-m .local/meetcap/vendor/whisper.cpp/models/ggml-large-v3.bin \
-f media/sample_meeting_meeting_20260501.wav \
-oj \
-of media/sample_meeting_meeting_20260501.large-v3 \
-l auto \
-t 8 \
-ng \
-mc 0 \
-bs 1 \
-bo 1 \
-ml 80.local/meetcap/vendor/whisper.cpp/build/bin/whisper-cli \
-m .local/meetcap/vendor/whisper.cpp/models/ggml-medium.bin \
-f media/sample_meeting_meeting_20260501.wav \
-oj \
-of media/sample_meeting_meeting_20260501.medium \
-l auto \
-t 8 \
-ng \
-mc 0 \
-bs 1 \
-bo 1If the command fails on macOS with a Metal buffer allocation error, keep the
same build and disable GPU for local execution with -ng.
Then configure the MeetCap worker:
MEETCAP_DATA_ROOT=.local/meetcap/data
MEETCAP_SQLITE_PATH=.local/meetcap/data/meetcap.sqlite3
MEETCAP_WORKER_WHISPER_CLI_PATH=.local/meetcap/vendor/whisper.cpp/build/bin/whisper-cli
MEETCAP_WORKER_WHISPER_MODEL_PATH=.local/meetcap/models/ggml-base.bin
MEETCAP_WORKER_WHISPER_PROMPT=
MEETCAP_WORKER_WHISPER_VAD=true
MEETCAP_WORKER_WHISPER_VAD_MODEL_PATH=.local/meetcap/models/for-tests-silero-v6.2.0-ggml.bin
MEETCAP_WORKER_MODEL=base
MEETCAP_WORKER_WHISPER_THREADS=8
MEETCAP_WORKER_WHISPER_NO_GPU=trueMEETCAP_WORKER_WHISPER_THREADS=8 is a conservative local starting point for
an M3 Max with 42 GB RAM. Benchmark 4, 8, and 12 before changing the
default for shared docs or VPS deployment.
A local setup can keep .local/meetcap/whisper.env with these repo-local paths
preconfigured for worker runs and gated model tests. The file uses plain dotenv
syntax, compatible with infra/docker-compose/.env. Load it in both bot and
worker shells with automatic export enabled so /meeting stop submits audio
paths under the same MEETCAP_DATA_ROOT that the worker validates.
set -a
source .local/meetcap/whisper.env
set +aFor the gated real model integration test, use separate test variables:
MEETCAP_TEST_WHISPER_CLI=.local/meetcap/vendor/whisper.cpp/build/bin/whisper-cli
MEETCAP_TEST_WHISPER_MODEL=.local/meetcap/models/ggml-base.bin
MEETCAP_TEST_WHISPER_FIXTURE=.local/meetcap/vendor/whisper.cpp/samples/jfk.wav
MEETCAP_TEST_WHISPER_NO_GPU=trueThen load the file before running the test:
set -a
source .local/meetcap/whisper.env
set +a
cargo test -p meetcap-transcription-worker --test worker_whisper_backend_integration -- --nocaptureWhen these MEETCAP_TEST_WHISPER_* variables are unset, the test skips cleanly.
The fixture must be a 16-bit WAV; convert other audio with:
mkdir -p .local/meetcap/fixtures
ffmpeg -i input.m4a -ar 16000 -ac 1 -c:a pcm_s16le .local/meetcap/fixtures/small-speech.wavT-024 validates the production-style transcription-worker container, not just
the host cargo path. The validation stack builds
apps/transcription-worker/Dockerfile, mounts the local model and data
directories, starts the worker through
infra/docker-compose/docker-compose.validation.yml, waits for
GET /health/ready, submits a transcription job, polls
GET /jobs/{jobId}, and verifies that transcript and summary artifacts were
written under the mounted data root.
Prerequisites:
- Docker and Docker Compose plugin are installed and working.
.local/meetcap/models/ggml-base.binexists..local/meetcap/fixtures/small-speech.wavexists and is a mono 16 kHz PCM s16le WAV.
If you need to create the fixture:
mkdir -p .local/meetcap/fixtures
ffmpeg -i input.m4a -ar 16000 -ac 1 -c:a pcm_s16le .local/meetcap/fixtures/small-speech.wavRun the validation:
bash infra/docker/ci/validate-worker-docker.shExpected flow:
- The script checks that the model and WAV fixture exist.
- It builds and starts the validation worker container on
localhost:8080. - It waits for
GET /health/readyto return{"ok":true,...}. - It copies the fixture into
.local/meetcap/data/sessions/<meeting-id>/processed/recording.wav. - It submits
POST /jobs/transcribewithsummary: true. - It polls
GET /jobs/{jobId}until the status becomescompleted. - It verifies:
.local/meetcap/data/sessions/<meeting-id>/transcript/transcript.json.local/meetcap/data/sessions/<meeting-id>/summary/summary.md
Expected success output includes:
=== T-024: Docker-backed Worker Validation ===
Starting worker container...
Waiting for /health/ready...
Worker is ready!
Submitting transcription job...
Polling for job <job-id> completion...
Job completed successfully!
Verifying artifacts...
OK: Transcript found.
OK: Summary found.
=== Validation Successful! ===
If it fails:
Model not foundmeans.local/meetcap/models/ggml-base.binis missing.Fixture not foundmeans.local/meetcap/fixtures/small-speech.wavis missing.Worker health check failedusually means the container did not start correctly; inspectdocker compose -f infra/docker-compose/docker-compose.validation.yml logs.Job failedmeans the worker accepted the request butwhisper-clior artifact generation failed inside the container.Job timed outusually means the fixture is too large for the retry window or the worker is stalled; inspect container logs first.
Successful validation leaves evidence in the mounted host data directory under
.local/meetcap/data/sessions/.
Run a workspace compile check:
cargo check --workspaceRun formatting verification:
cargo fmt --all -- --checkApply formatting:
cargo fmt --allRun clippy with warnings treated as errors:
cargo clippy --workspace --all-targets -- -D warningsRecommended pre-handoff command set:
cargo fmt --all -- --check
cargo check --workspace
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warningsThe Gitea workflow lives at .gitea/workflows/main.yaml.
The pipeline is designed for a self-hosted Gitea repository. Sample endpoints:
https://meetcap.click:3443/MeetCapCompany/meet-cap-public.git
ssh://git@meetcap.click:2222/MeetCapCompany/meet-cap-public.git
It has two jobs:
| Job | Purpose |
|---|---|
build-and-test |
Checks out the repository, builds or reuses the Docker CI image, then runs Rust quality gates in the container. |
secret-scan |
Runs gitleaks in Docker and records a SARIF report. |
The Docker CI image is defined in infra/docker/ci/Dockerfile.rust and currently uses rust:1.93-bookworm.
The quality-gate entrypoint is infra/docker/ci/run-tests.sh. It runs:
cargo fmt --all -- --checkcargo clippy --workspace --all-targets -- -D warningscargo check --workspacecargo test --workspace --lib- CI-safe integration test discovery
cargo test --workspacecargo llvm-cov --workspace --all-targets --coberturadiff-coverwith an 80% changed-lines threshold when a compare branch is available
The workspace now includes CI-safe integration tests under apps/discord-bot/tests/; live Discord smoke tests remain manual and are not part of normal CI.
Known false-positive example secrets are documented in .gitleaksignore. Do not add real secrets to that file; rotate and remove real leaked credentials instead.
Run the Discord bot against a test guild after putting real values in untracked
infra/docker-compose/.env:
set -a
source infra/docker-compose/.env
set +a
cargo run -p meetcap-discord-botThe bot reads MEETCAP_DISCORD_BOT_TOKEN, connects through Discord Gateway with
Serenity, and registers the /meeting ... command tree. Use
MEETCAP_DISCORD_TEST_GUILD_ID for guild-scoped command registration during
development; otherwise commands are registered globally.
Run the transcription worker:
set -a
source infra/docker-compose/.env
set +a
cargo run -p meetcap-transcription-workerIf you keep Whisper settings separate instead of merging them into
infra/docker-compose/.env, source .local/meetcap/whisper.env with the same
set -a / set +a wrapper.
Expected output:
MeetCap transcription-worker listening on 127.0.0.1:8080
Without MEETCAP_WORKER_WHISPER_CLI_PATH and
MEETCAP_WORKER_WHISPER_MODEL_PATH, the worker exposes the HTTP contract but
keeps real STT execution disabled. With those variables set, accepted jobs are
processed by whisper-cli.
Run all tests:
cargo test --workspaceRun tests for one package:
cargo test -p meetcap-domain
cargo test -p meetcap-discord-bot
cargo test -p meetcap-transcription-workerThe current tests cover package wiring, shared domain state transitions, worker DTO JSON compatibility, and Discord command parsing/dispatch without live Discord API calls.
Docker Compose files live under infra/docker-compose/.
| File | Purpose |
|---|---|
docker-compose.yml |
Local/dev external dependencies only. It currently has no services because MVP local dev uses SQLite/file storage. |
docker-compose.deploy.yml |
VPS runtime shape using prebuilt bot and worker images. |
.env.example |
Redacted local template. Copy to untracked .env before real local Discord tests. |
.env.deploy.example |
Redacted deploy template. Real deploy values must come from Ansible Vault or non-committed host vars. |
Local dev with cargo:
cp infra/docker-compose/.env.example infra/docker-compose/.env
set -a
source infra/docker-compose/.env
set +a
cargo run -p meetcap-transcription-workerRun the bot from a second shell after adding a real
MEETCAP_DISCORD_BOT_TOKEN and sourcing the same env file:
set -a
source infra/docker-compose/.env
set +a
cargo run -p meetcap-discord-botThe bot is now a long-running Gateway process when a valid Discord token is
provided. The worker is a long-running HTTP process; real transcription runs
only when the whisper-cli binary and model paths are configured. When future
external services such as PostgreSQL, Redis, or Kafka enter scope, add them to
the local Compose file and start them before running cargo.
- hostinger KVM-4 (4 vCPU, 16 GB RAM, 200 GB NVMe), KVM-8(8 vCPU, 32 GB RAM, 400 GB NVMe). Get 20% Discount using this referral link: Referral Link
Ansible files live under infra/ansible/.
| Path | Purpose |
|---|---|
ansible.cfg |
Inventory and role path defaults. |
inventory/production.ini.example |
Redacted production inventory template. |
playbooks/provision.yml |
Installs Docker/Compose and prepares the deploy directory. |
playbooks/check-docker-setup.yml |
Verifies Docker and Compose on the VPS. |
playbooks/deploy.yml |
Stages deploy Compose/env artifacts and can apply the stack when real vars are present. |
roles/docker |
Docker/Compose bootstrap role. |
roles/meetcap |
MeetCap deploy artifact and Compose role. |
Real inventories, Vault files, and host-specific vars are intentionally untracked.
The selected MVP STT pattern is: MeetCap transcription-worker shells out to
whisper-cli. Local cargo uses host paths; VPS/Docker should use fixed
container paths such as /usr/local/bin/whisper-cli and
/models/ggml-base.bin.
| Variable | Required | Description |
|---|---|---|
MEETCAP_DISCORD_BOT_TOKEN |
Yes | Discord bot token for live Gateway usage. |
MEETCAP_DISCORD_TEST_GUILD_ID |
No | Test guild snowflake for guild-scoped command registration during development. |
MEETCAP_WORKER_BASE_URL |
No, later | Worker base URL. Local cargo uses http://127.0.0.1:8080; deploy Compose uses http://transcription-worker:8080. |
MEETCAP_WORKER_EXTERNAL_BASE_URL |
No | Optional external/public base URL used only for bot-rendered transcript links when the worker returned relative download paths. Example: http://demo.example.invalid:5580. |
MEETCAP_DATA_ROOT |
No | Shared data root for session metadata files, default /data. |
MEETCAP_SQLITE_PATH |
No | SQLite metadata database path, default {MEETCAP_DATA_ROOT}/meetcap.sqlite3. |
MEETCAP_ALLOWED_ROLE_IDS |
Yes for meeting commands | Comma-separated Discord role IDs allowed to run meeting commands. Empty means deny. |
MEETCAP_ALLOWED_VOICE_CHANNEL_IDS |
Yes for /meeting start |
Comma-separated Discord voice channel IDs allowed for recording. Empty means deny start. |
MEETCAP_ALLOWED_TEXT_CHANNEL_IDS |
Yes for /meeting start |
Comma-separated Discord text channel IDs allowed for status/output references. Empty means deny start. |
RUST_LOG |
No, later | Rust log filter, for example info or meetcap=debug. |
| Variable | Required | Description |
|---|---|---|
MEETCAP_WORKER_BIND_ADDR |
No, later | Worker HTTP bind address, expected default 0.0.0.0:8080. |
MEETCAP_WORKER_API_TOKEN |
Conditional, later | Bearer token if the internal worker API is exposed outside a private network. |
MEETCAP_WORKER_ARTIFACT_BASE_URL |
No | Optional public base URL used to prefix safe transcript download paths. If unset, worker responses use relative internal API paths. |
MEETCAP_DATA_ROOT |
No | Shared data root, expected default /data. |
MEETCAP_SQLITE_PATH |
No | SQLite metadata database path. |
MEETCAP_WORKER_MODEL |
No | Model label reported in worker API responses, for example base. |
MEETCAP_WORKER_WHISPER_CLI_PATH |
Yes for real STT | Path to the whisper-cli executable. Local example: /absolute/path/to/whisper.cpp/build/bin/whisper-cli; Docker example: /usr/local/bin/whisper-cli. |
MEETCAP_WORKER_WHISPER_MODEL_PATH |
Yes for real STT | Path to the configured whisper.cpp model file. Local example: /absolute/path/to/models/ggml-base.bin; Docker example: /models/ggml-base.bin. |
MEETCAP_WORKER_WHISPER_PROMPT |
No | Optional one-line whisper-cli --prompt value. Leave unset by default for mixed-language meetings; enable only when you explicitly want decoder bias toward a glossary or domain wording. |
MEETCAP_WORKER_WHISPER_VAD |
No | Boolean VAD switch. Set false to disable VAD even if a Silero model is present, true to force-enable VAD and allow model autodiscovery, or leave unset to preserve the legacy autodiscovery behavior. |
MEETCAP_WORKER_WHISPER_VAD_MODEL_PATH |
No | Optional Silero VAD model path. Used when VAD is enabled; if omitted, the worker auto-enables a known Silero model found next to the Whisper model. |
MEETCAP_WORKER_WHISPER_THREADS |
No | CPU thread count for whisper-cli, for example 4. |
MEETCAP_WORKER_WHISPER_NO_GPU |
No | Set true to pass -ng to whisper-cli and force CPU/Accelerate execution. Useful on macOS if Metal allocation fails. |
RUST_LOG |
No | Rust log filter, for example info or meetcap=debug. |
Secrets must not be committed. Keep real values in untracked infra/docker-compose/.env, untracked infra/docker-compose/.env.deploy, Ansible Vault, or host-specific inventory variables.
Read these before starting implementation work:
| Document | Purpose |
|---|---|
docs/handoff.md |
Current session state, recent changes, risks, and next steps. |
docs/scope.md |
Product boundaries, milestones, success metrics, dependencies, and non-goals. |
docs/design.md |
Architecture, service boundaries, test requirements, deployment approach, and risks. |
docs/api_protocol_v1.md |
Internal bot-to-worker HTTP protocol for transcription and summaries. |
docs/bot_user_manual.md |
Bot setup, user journeys, and manual/automation use-case requirements. |
docs/tracker.md |
Task source of truth with acceptance criteria and status. |
docs/methodology/methodology.md |
Process gates, Definition of Done, testing, security, CI, and handoff rules. |
- See
docs/design.mdanddocs/tracker.mdfor the implementation order. - Pick the next tracker task or confirm the intended task with the maintainer.
- Keep changes scoped to the tracker task and referenced design section.
- Prefer small slices that can be validated in less than one day.
- Add or update tests for nontrivial logic.
- Run the local validation commands before handing off.
- Update
docs/tracker.mdwith status and evidence. - Update
docs/handoff.mdusing the canonical schema fromdocs/methodology/methodology.md.
- Never commit Discord tokens, worker API tokens, model credentials, VPS credentials, or Ansible Vault passwords.
- Keep local secrets in untracked files.
- Prefer redacted committed templates such as
.env.exampleonce infra exists. - CI runs gitleaks for secret scanning; keep known false-positive examples in
.gitleaksignoreonly when they are documented non-secrets.